Bläddra i källkod

Make roles mean something (#271)

Bernt Christian Egeland 1 månad sedan
förälder
incheckning
51143b6303

+ 2 - 1
messages/de/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "WhatsApp-Einstellungen aktualisiert",
     "settings_whatsappDisconnected": "WhatsApp getrennt",
     "settings_whatsappNumberRegistered": "WhatsApp-Nummer registriert",
-    "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben"
+    "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben",
+    "team_createDefaultRoles": "Standardrollen angelegt"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",

+ 12 - 1
messages/de/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Alles, was bereits auf sie gebucht ist, bleibt erhalten.",
     "giveAppSubmit": "Konto anlegen",
     "role": "Rolle",
-    "inviteMemberHint": "Die Person erhält eine E-Mail und wählt ihr eigenes Passwort."
+    "inviteMemberHint": "Die Person erhält eine E-Mail und wählt ihr eigenes Passwort.",
+    "removeBoardOnlyTitle": "{name} von der Tafel nehmen?",
+    "removeBoardOnlyBody": "{name} erscheint nicht mehr auf der Werkstatttafel und kann keinen Aufträgen mehr zugewiesen werden. Bereits erfasste Arbeit und gestempelte Stunden bleiben genau so, wie sie sind.",
+    "removeBoardOnlyDone": "{name} ist von der Tafel",
+    "noRole": "Keine Rolle",
+    "worksOnJobs": "Arbeitet an Aufträgen",
+    "createDefaultRoles": "Standardrollen anlegen",
+    "defaultRolesCreated": "Standardrollen angelegt",
+    "noRolesYet": "Diese Werkstatt hat noch keine Rollen, außer Admin gibt es also nichts zu vergeben. Legen Sie die Standardrollen an und schränken Sie die Rechte später ein.",
+    "noRoleDescription": "Überhaupt keine Rechte. Sie können sich anmelden, aber jeder Bildschirm weist sie ab, bis Sie ihnen unten eine Rolle geben.",
+    "cannotUseApp": "Diese Rolle kann keine Aufträge bearbeiten. Geben Sie ihr zuerst Techniker oder eine Rolle mit denselben Rechten.",
+    "revokeTechnicianDone": "{name} wurde aus der App abgemeldet"
   },
   "invoice": {
     "title": "Rechnungslayout",

+ 2 - 1
messages/en/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Updated WhatsApp settings",
     "settings_whatsappDisconnected": "Disconnected WhatsApp",
     "settings_whatsappNumberRegistered": "Registered a WhatsApp number",
-    "team_giveTechnicianTheApp": "Gave a board-only technician an account"
+    "team_giveTechnicianTheApp": "Gave a board-only technician an account",
+    "team_createDefaultRoles": "Created the standard roles"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",

+ 12 - 1
messages/en/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Everything already recorded against them stays where it is.",
     "giveAppSubmit": "Create their account",
     "role": "Role",
-    "inviteMemberHint": "They get an email and choose their own password."
+    "inviteMemberHint": "They get an email and choose their own password.",
+    "removeBoardOnlyTitle": "Take {name} off the board?",
+    "removeBoardOnlyBody": "{name} stops appearing on the work board and can no longer be put on jobs. Work already recorded against them, and the hours they clocked, stay exactly as they are.",
+    "removeBoardOnlyDone": "{name} is off the board",
+    "noRole": "No role",
+    "worksOnJobs": "Works on jobs",
+    "createDefaultRoles": "Create the standard roles",
+    "defaultRolesCreated": "Standard roles created",
+    "noRolesYet": "This workshop has no roles yet, so there is nothing to give them beyond Admin. Create the standard ones and narrow the permissions later.",
+    "noRoleDescription": "No permissions at all. They can sign in and every screen will refuse them until you give them a role below.",
+    "cannotUseApp": "Their role cannot work on jobs. Give them Technician, or a role with the same permissions, first.",
+    "revokeTechnicianDone": "{name} has been signed out of the app"
   },
   "invoice": {
     "title": "Invoice & Quotes",

+ 2 - 1
messages/es/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Ajustes de WhatsApp actualizados",
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappNumberRegistered": "Número de WhatsApp registrado",
-    "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero"
+    "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero",
+    "team_createDefaultRoles": "Roles estándar creados"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",

+ 12 - 1
messages/es/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Todo lo que ya tiene registrado se queda como está.",
     "giveAppSubmit": "Crear su cuenta",
     "role": "Rol",
-    "inviteMemberHint": "Recibe un correo y elige su propia contraseña."
+    "inviteMemberHint": "Recibe un correo y elige su propia contraseña.",
+    "removeBoardOnlyTitle": "¿Quitar a {name} del tablero?",
+    "removeBoardOnlyBody": "{name} deja de aparecer en el tablero y ya no se le puede asignar trabajos. El trabajo ya registrado y las horas fichadas quedan exactamente igual.",
+    "removeBoardOnlyDone": "{name} está fuera del tablero",
+    "noRole": "Sin rol",
+    "worksOnJobs": "Trabaja en los coches",
+    "createDefaultRoles": "Crear los roles estándar",
+    "defaultRolesCreated": "Roles estándar creados",
+    "noRolesYet": "Este taller aún no tiene roles, así que no hay nada que darle aparte de Admin. Cree los estándar y ajuste los permisos después.",
+    "noRoleDescription": "Sin permisos. Puede iniciar sesión, pero todas las pantallas lo rechazarán hasta que le dé un rol de los de abajo.",
+    "cannotUseApp": "Su rol no puede trabajar en los trabajos. Déle primero Técnico, o un rol con los mismos permisos.",
+    "revokeTechnicianDone": "{name} ha salido de la aplicación"
   },
   "invoice": {
     "title": "Diseño de factura",

+ 2 - 1
messages/fr/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Paramètres WhatsApp mis à jour",
     "settings_whatsappDisconnected": "WhatsApp déconnecté",
     "settings_whatsappNumberRegistered": "Numéro WhatsApp enregistré",
-    "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau"
+    "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau",
+    "team_createDefaultRoles": "Rôles standard créés"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",

+ 12 - 1
messages/fr/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Tout ce qui lui est déjà attribué reste en place.",
     "giveAppSubmit": "Créer son compte",
     "role": "Rôle",
-    "inviteMemberHint": "La personne reçoit un e-mail et choisit son propre mot de passe."
+    "inviteMemberHint": "La personne reçoit un e-mail et choisit son propre mot de passe.",
+    "removeBoardOnlyTitle": "Retirer {name} du planning ?",
+    "removeBoardOnlyBody": "{name} n’apparaît plus sur le planning et ne peut plus être affecté à des travaux. Le travail déjà enregistré et les heures pointées restent exactement tels quels.",
+    "removeBoardOnlyDone": "{name} est retiré du planning",
+    "noRole": "Aucun rôle",
+    "worksOnJobs": "Travaille sur les véhicules",
+    "createDefaultRoles": "Créer les rôles standard",
+    "defaultRolesCreated": "Rôles standard créés",
+    "noRolesYet": "Cet atelier n’a encore aucun rôle, il n’y a donc rien à leur donner en dehors d’Admin. Créez les rôles standard et affinez les permissions plus tard.",
+    "noRoleDescription": "Aucune permission. La personne peut se connecter, mais chaque écran la refusera tant que vous ne lui aurez pas donné un rôle ci-dessous.",
+    "cannotUseApp": "Son rôle ne permet pas de travailler sur les travaux. Donnez-lui d’abord Technicien, ou un rôle ayant les mêmes permissions.",
+    "revokeTechnicianDone": "{name} a été déconnecté de l’application"
   },
   "invoice": {
     "title": "Mise en page de facture",

+ 2 - 1
messages/it/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Impostazioni WhatsApp aggiornate",
     "settings_whatsappDisconnected": "WhatsApp disconnesso",
     "settings_whatsappNumberRegistered": "Numero WhatsApp registrato",
-    "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna"
+    "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna",
+    "team_createDefaultRoles": "Ruoli standard creati"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",

+ 12 - 1
messages/it/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Tutto ciò che è già registrato a suo nome resta dov’è.",
     "giveAppSubmit": "Crea il suo account",
     "role": "Ruolo",
-    "inviteMemberHint": "Riceve un’email e sceglie la propria password."
+    "inviteMemberHint": "Riceve un’email e sceglie la propria password.",
+    "removeBoardOnlyTitle": "Togliere {name} dalla lavagna?",
+    "removeBoardOnlyBody": "{name} non compare più sulla lavagna e non può più essere assegnato ai lavori. Il lavoro già registrato e le ore timbrate restano esattamente come sono.",
+    "removeBoardOnlyDone": "{name} è fuori dalla lavagna",
+    "noRole": "Nessun ruolo",
+    "worksOnJobs": "Lavora sui veicoli",
+    "createDefaultRoles": "Crea i ruoli standard",
+    "defaultRolesCreated": "Ruoli standard creati",
+    "noRolesYet": "Questa officina non ha ancora ruoli, quindi non c’è nulla da assegnare oltre ad Admin. Crea quelli standard e restringi i permessi dopo.",
+    "noRoleDescription": "Nessun permesso. Può accedere, ma ogni schermata lo rifiuta finché non gli assegni un ruolo qui sotto.",
+    "cannotUseApp": "Il suo ruolo non può lavorare sui lavori. Assegnagli prima Tecnico, o un ruolo con gli stessi permessi.",
+    "revokeTechnicianDone": "{name} è stato disconnesso dall’app"
   },
   "invoice": {
     "title": "Layout fattura",

+ 2 - 1
messages/lt/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Atnaujinti „WhatsApp“ nustatymai",
     "settings_whatsappDisconnected": "Atjungtas „WhatsApp“",
     "settings_whatsappNumberRegistered": "Užregistruotas „WhatsApp“ numeris",
-    "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra"
+    "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra",
+    "team_createDefaultRoles": "Sukurtos standartinės rolės"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",

+ 12 - 1
messages/lt/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Viskas, kas jam jau užfiksuota, lieka savo vietoje.",
     "giveAppSubmit": "Sukurti paskyrą",
     "role": "Vaidmuo",
-    "inviteMemberHint": "Jis gaus laišką ir pats pasirinks slaptažodį."
+    "inviteMemberHint": "Jis gaus laišką ir pats pasirinks slaptažodį.",
+    "removeBoardOnlyTitle": "Pašalinti {name} iš lentos?",
+    "removeBoardOnlyBody": "{name} nebesirodo darbų lentoje ir jo nebegalima priskirti darbams. Jau užfiksuotas darbas ir pažymėtos valandos lieka lygiai tokie patys.",
+    "removeBoardOnlyDone": "{name} pašalintas iš lentos",
+    "noRole": "Nėra rolės",
+    "worksOnJobs": "Dirba su automobiliais",
+    "createDefaultRoles": "Sukurti standartines roles",
+    "defaultRolesCreated": "Standartinės rolės sukurtos",
+    "noRolesYet": "Šios dirbtuvės dar neturi rolių, todėl, be administratoriaus, nėra ko suteikti. Sukurkite standartines ir teises patikslinkite vėliau.",
+    "noRoleDescription": "Jokių teisių. Gali prisijungti, bet kiekvienas ekranas jį atmes, kol nesuteiksite rolės žemiau.",
+    "cannotUseApp": "Jo rolė neleidžia dirbti su užsakymais. Pirma suteikite meistro rolę arba rolę su tomis pačiomis teisėmis.",
+    "revokeTechnicianDone": "{name} atjungtas nuo programėlės"
   },
   "invoice": {
     "title": "Sąskaitos ir pasiūlymai",

+ 2 - 1
messages/nb/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Oppdaterte WhatsApp-innstillinger",
     "settings_whatsappDisconnected": "Koblet fra WhatsApp",
     "settings_whatsappNumberRegistered": "Registrerte et WhatsApp-nummer",
-    "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto"
+    "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto",
+    "team_createDefaultRoles": "Opprettet standardrollene"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",

+ 12 - 1
messages/nb/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Alt som allerede er ført på dem blir stående.",
     "giveAppSubmit": "Opprett kontoen",
     "role": "Rolle",
-    "inviteMemberHint": "Personen får en e-post og velger sitt eget passord."
+    "inviteMemberHint": "Personen får en e-post og velger sitt eget passord.",
+    "removeBoardOnlyTitle": "Ta {name} av tavlen?",
+    "removeBoardOnlyBody": "{name} vises ikke lenger på arbeidstavlen og kan ikke settes på jobber. Arbeid som allerede er registrert på dem, og timene de har stemplet, blir stående nøyaktig som de er.",
+    "removeBoardOnlyDone": "{name} er tatt av tavlen",
+    "noRole": "Ingen rolle",
+    "worksOnJobs": "Jobber på biler",
+    "createDefaultRoles": "Opprett standardrollene",
+    "defaultRolesCreated": "Standardroller opprettet",
+    "noRolesYet": "Dette verkstedet har ingen roller ennå, så det finnes ingenting å gi dem utover Admin. Opprett standardrollene og juster tillatelsene senere.",
+    "noRoleDescription": "Ingen tillatelser i det hele tatt. De kan logge inn, men hver skjerm avviser dem til du gir dem en rolle nedenfor.",
+    "cannotUseApp": "Rollen deres kan ikke jobbe på jobber. Gi dem Tekniker, eller en rolle med de samme tillatelsene, først.",
+    "revokeTechnicianDone": "{name} er logget ut av appen"
   },
   "invoice": {
     "title": "Faktura og tilbud",

+ 2 - 1
messages/nl/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "WhatsApp-instellingen bijgewerkt",
     "settings_whatsappDisconnected": "WhatsApp losgekoppeld",
     "settings_whatsappNumberRegistered": "WhatsApp-nummer geregistreerd",
-    "team_giveTechnicianTheApp": "Bordmonteur een account gegeven"
+    "team_giveTechnicianTheApp": "Bordmonteur een account gegeven",
+    "team_createDefaultRoles": "Standaardrollen aangemaakt"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",

+ 12 - 1
messages/nl/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Alles wat al op hen staat blijft staan.",
     "giveAppSubmit": "Account aanmaken",
     "role": "Rol",
-    "inviteMemberHint": "Deze persoon krijgt een e-mail en kiest zelf een wachtwoord."
+    "inviteMemberHint": "Deze persoon krijgt een e-mail en kiest zelf een wachtwoord.",
+    "removeBoardOnlyTitle": "{name} van het bord halen?",
+    "removeBoardOnlyBody": "{name} verschijnt niet meer op het planbord en kan niet meer op klussen worden gezet. Al vastgelegd werk en geklokte uren blijven precies zoals ze zijn.",
+    "removeBoardOnlyDone": "{name} staat niet meer op het bord",
+    "noRole": "Geen rol",
+    "worksOnJobs": "Werkt aan klussen",
+    "createDefaultRoles": "Standaardrollen aanmaken",
+    "defaultRolesCreated": "Standaardrollen aangemaakt",
+    "noRolesYet": "Deze werkplaats heeft nog geen rollen, dus er valt niets te geven behalve Admin. Maak de standaardrollen aan en verfijn de rechten later.",
+    "noRoleDescription": "Geen rechten. Ze kunnen inloggen, maar elk scherm weigert ze tot u hieronder een rol toekent.",
+    "cannotUseApp": "Hun rol kan niet aan klussen werken. Geef ze eerst Monteur, of een rol met dezelfde rechten.",
+    "revokeTechnicianDone": "{name} is uitgelogd uit de app"
   },
   "invoice": {
     "title": "Factuurindeling",

+ 2 - 1
messages/pl/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Zaktualizowano ustawienia WhatsApp",
     "settings_whatsappDisconnected": "Odłączono WhatsApp",
     "settings_whatsappNumberRegistered": "Zarejestrowano numer WhatsApp",
-    "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy"
+    "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy",
+    "team_createDefaultRoles": "Utworzono role standardowe"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",

+ 12 - 1
messages/pl/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Wszystko, co już zapisano na tę osobę, zostaje.",
     "giveAppSubmit": "Utwórz konto",
     "role": "Rola",
-    "inviteMemberHint": "Otrzyma e-mail i sam wybierze hasło."
+    "inviteMemberHint": "Otrzyma e-mail i sam wybierze hasło.",
+    "removeBoardOnlyTitle": "Zdjąć {name} z tablicy?",
+    "removeBoardOnlyBody": "{name} przestaje pojawiać się na tablicy i nie można go przypisywać do zleceń. Zapisana już praca i zarejestrowane godziny pozostają bez zmian.",
+    "removeBoardOnlyDone": "{name} zdjęty z tablicy",
+    "noRole": "Brak roli",
+    "worksOnJobs": "Pracuje przy autach",
+    "createDefaultRoles": "Utwórz role standardowe",
+    "defaultRolesCreated": "Utworzono role standardowe",
+    "noRolesYet": "Ten warsztat nie ma jeszcze żadnych ról, więc poza Adminem nie ma czego przydzielić. Utwórz standardowe i dopracuj uprawnienia później.",
+    "noRoleDescription": "Brak jakichkolwiek uprawnień. Może się zalogować, ale każdy ekran go odrzuci, dopóki nie nadasz mu roli poniżej.",
+    "cannotUseApp": "Ta rola nie może pracować przy zleceniach. Nadaj najpierw rolę Technik lub inną z tymi samymi uprawnieniami.",
+    "revokeTechnicianDone": "{name} został wylogowany z aplikacji"
   },
   "invoice": {
     "title": "Układ faktury",

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

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Configurações do WhatsApp atualizadas",
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappNumberRegistered": "Número do WhatsApp registrado",
-    "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro"
+    "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro",
+    "team_createDefaultRoles": "Funções padrão criadas"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",

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

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Tudo o que já está registrado para essa pessoa continua como está.",
     "giveAppSubmit": "Criar a conta",
     "role": "Função",
-    "inviteMemberHint": "Ela recebe um e-mail e escolhe a própria senha."
+    "inviteMemberHint": "Ela recebe um e-mail e escolhe a própria senha.",
+    "removeBoardOnlyTitle": "Tirar {name} do quadro?",
+    "removeBoardOnlyBody": "{name} deixa de aparecer no quadro e não pode mais ser colocado em serviços. O trabalho já registrado e as horas marcadas ficam exatamente como estão.",
+    "removeBoardOnlyDone": "{name} saiu do quadro",
+    "noRole": "Sem função",
+    "worksOnJobs": "Trabalha nos serviços",
+    "createDefaultRoles": "Criar as funções padrão",
+    "defaultRolesCreated": "Funções padrão criadas",
+    "noRolesYet": "Esta oficina ainda não tem funções, então não há o que dar além de Admin. Crie as padrão e ajuste as permissões depois.",
+    "noRoleDescription": "Nenhuma permissão. A pessoa consegue entrar, mas todas as telas a recusam até você dar uma função abaixo.",
+    "cannotUseApp": "A função dela não permite trabalhar em serviços. Dê primeiro Técnico, ou uma função com as mesmas permissões.",
+    "revokeTechnicianDone": "{name} foi desconectado do aplicativo"
   },
   "invoice": {
     "title": "Layout da fatura",

+ 2 - 1
messages/ru/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "Обновлены настройки WhatsApp",
     "settings_whatsappDisconnected": "WhatsApp отключён",
     "settings_whatsappNumberRegistered": "Зарегистрирован номер WhatsApp",
-    "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись"
+    "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись",
+    "team_createDefaultRoles": "Созданы стандартные роли"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",

+ 12 - 1
messages/ru/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Всё, что уже записано на этого человека, остаётся на месте.",
     "giveAppSubmit": "Создать учётную запись",
     "role": "Роль",
-    "inviteMemberHint": "Ему придёт письмо, и он выберет пароль сам."
+    "inviteMemberHint": "Ему придёт письмо, и он выберет пароль сам.",
+    "removeBoardOnlyTitle": "Убрать {name} с доски?",
+    "removeBoardOnlyBody": "{name} больше не появляется на рабочей доске, и его нельзя назначать на заказ-наряды. Уже записанная работа и отмеченные часы остаются ровно такими же.",
+    "removeBoardOnlyDone": "{name} убран с доски",
+    "noRole": "Без роли",
+    "worksOnJobs": "Работает с машинами",
+    "createDefaultRoles": "Создать стандартные роли",
+    "defaultRolesCreated": "Стандартные роли созданы",
+    "noRolesYet": "В этой мастерской пока нет ролей, поэтому дать нечего, кроме роли администратора. Создайте стандартные и настройте права позже.",
+    "noRoleDescription": "Никаких прав. Такой человек может войти, но каждый экран будет ему отказывать, пока вы не назначите роль ниже.",
+    "cannotUseApp": "Его роль не позволяет работать с заказ-нарядами. Сначала назначьте роль механика или роль с теми же правами.",
+    "revokeTechnicianDone": "{name} вышел из приложения"
   },
   "invoice": {
     "title": "Счета и предложения",

+ 2 - 1
messages/tr/audit.json

@@ -149,7 +149,8 @@
     "settings_whatsappUpdated": "WhatsApp ayarları güncellendi",
     "settings_whatsappDisconnected": "WhatsApp bağlantısı kesildi",
     "settings_whatsappNumberRegistered": "WhatsApp numarası kaydedildi",
-    "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi"
+    "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi",
+    "team_createDefaultRoles": "Standart roller oluşturuldu"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",

+ 12 - 1
messages/tr/settings.json

@@ -482,7 +482,18 @@
     "giveAppKeepsHistory": "Onun adına kayıtlı olan her şey olduğu gibi kalır.",
     "giveAppSubmit": "Hesabını oluştur",
     "role": "Rol",
-    "inviteMemberHint": "Bir e-posta alır ve kendi parolasını seçer."
+    "inviteMemberHint": "Bir e-posta alır ve kendi parolasını seçer.",
+    "removeBoardOnlyTitle": "{name} panodan kaldırılsın mı?",
+    "removeBoardOnlyBody": "{name} artık iş panosunda görünmez ve işlere atanamaz. Daha önce kaydedilen işler ve girilen saatler olduğu gibi kalır.",
+    "removeBoardOnlyDone": "{name} panodan kaldırıldı",
+    "noRole": "Rol yok",
+    "worksOnJobs": "Araçlarda çalışır",
+    "createDefaultRoles": "Standart rolleri oluştur",
+    "defaultRolesCreated": "Standart roller oluşturuldu",
+    "noRolesYet": "Bu atölyede henüz rol yok, bu yüzden Yönetici dışında verilecek bir şey bulunmuyor. Standart rolleri oluşturup izinleri sonra daraltın.",
+    "noRoleDescription": "Hiçbir izni yok. Giriş yapabilir ama aşağıdan bir rol verene kadar her ekran onu reddeder.",
+    "cannotUseApp": "Rolü işler üzerinde çalışmaya izin vermiyor. Önce Teknisyen ya da aynı izinlere sahip bir rol verin.",
+    "revokeTechnicianDone": "{name} uygulamadan çıkarıldı"
   },
   "invoice": {
     "title": "Fatura Düzeni",

+ 100 - 0
src/__tests__/features/default-roles.test.ts

@@ -0,0 +1,100 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+  MEMBER_PERMISSIONS,
+  MEMBER_ROLE_NAME,
+  TECHNICIAN_ROLE_NAME,
+} from '@/features/team/Lib/technicianRole'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+
+const read = (p: string) => fs.readFileSync(path.join(process.cwd(), p), 'utf-8')
+
+/**
+ * The two roles a workshop is offered, and the promise that offering them
+ * cannot lock anybody out.
+ *
+ * Nothing seeded roles when an organization was created, so every workshop
+ * began with none and "Member" in the role dropdown granted nothing: `member`
+ * is Better Auth's membership tier, not a permission set. These are the roles
+ * that word should have meant. They are created on request, never in a
+ * migration, because an install that has carefully narrowed its own roles must
+ * not find two more appearing or, far worse, its existing ones rewritten.
+ */
+describe('the standard roles', () => {
+  it('give the desk enough to run the day', () => {
+    const has = (action: PermissionAction, subject: PermissionSubject) =>
+      MEMBER_PERMISSIONS.some((p) => p.action === action && p.subject === subject)
+
+    expect(has(PermissionAction.READ, PermissionSubject.CUSTOMERS)).toBe(true)
+    expect(has(PermissionAction.CREATE, PermissionSubject.WORK_ORDERS)).toBe(true)
+    expect(has(PermissionAction.UPDATE, PermissionSubject.SERVICES)).toBe(true)
+  })
+
+  it('keeps the owner’s business out of the desk role', () => {
+    // Settings carries team management, which is how a member would promote
+    // themselves. Billing and reports are the owner's to see.
+    const reserved = [
+      PermissionSubject.SETTINGS,
+      PermissionSubject.BILLING,
+      PermissionSubject.REPORTS,
+    ]
+    const leaked = MEMBER_PERMISSIONS.filter((p) =>
+      reserved.includes(p.subject as PermissionSubject)
+    )
+    expect(
+      leaked,
+      `The desk role must not carry ${leaked.map((p) => p.subject).join(', ')}`
+    ).toEqual([])
+  })
+
+  it('never grants admin through a flag', () => {
+    const lib = read('src/features/team/Lib/technicianRole.ts')
+    // isAdmin bypasses every permission check, which hides what an account can
+    // reach behind a boolean. Both roles are ordinary sets of permissions.
+    expect(lib.match(/isAdmin: false/g)?.length).toBe(2)
+    expect(lib).not.toMatch(/isAdmin: true/)
+  })
+
+  describe('are safe to add to a workshop that already has roles', () => {
+    const lib = read('src/features/team/Lib/technicianRole.ts')
+    const action = read('src/features/team/Actions/createDefaultRoles.ts')
+
+    it('creates, and never edits or removes, an existing role', () => {
+      for (const [name, src] of [
+        ['technicianRole.ts', lib],
+        ['createDefaultRoles.ts', action],
+      ] as const) {
+        expect(src, `${name} must not update a role`).not.toMatch(/role\.update/)
+        expect(src, `${name} must not delete a role`).not.toMatch(/role\.delete/)
+        expect(src, `${name} must not touch permissions of an existing role`).not.toMatch(
+          /permission\.(delete|update)/
+        )
+      }
+    })
+
+    it('returns the role already there rather than a second one', () => {
+      // Both helpers look the role up by name first and return early.
+      for (const name of [MEMBER_ROLE_NAME, TECHNICIAN_ROLE_NAME]) {
+        expect(lib).toContain(
+          `name: ${name === MEMBER_ROLE_NAME ? 'MEMBER' : 'TECHNICIAN'}_ROLE_NAME`
+        )
+      }
+      expect(lib.match(/if \(existing\) return existing\.id/g)?.length).toBe(2)
+    })
+  })
+
+  /**
+   * The guard rail for the bug this replaced: choosing any role other than
+   * Technician deactivated that person's technician record, so a desk person
+   * who also works on cars could not exist, and a role change silently took
+   * somebody's phone away.
+   */
+  it('does not let a role change take somebody off the board', () => {
+    const src = read('src/features/team/Actions/assignRole.ts')
+    const calls = src.match(/setTechnicianStanding\([\s\S]*?\)/g) ?? []
+    expect(calls.length, 'assignRole should set technician standing once').toBe(1)
+    expect(calls[0], 'assignRole may only ever activate, never deactivate').toContain('true')
+    expect(calls[0]).not.toMatch(/false|asTechnician,/)
+  })
+})

+ 44 - 21
src/__tests__/features/team-role-value.test.ts

@@ -6,16 +6,20 @@ import { TECHNICIAN_ROLE_NAME } from '@/features/team/Lib/technicianRole'
  *
  * The trigger renders blank when the value matches no option, and a blank
  * trigger reads as "this person has no role", which is the one thing it must
- * never say about somebody who does. That happened for a newly added
- * technician: their role id was real but deliberately absent from the list,
- * because the Technician entry above it is what grants it.
+ * never say about somebody who does.
+ *
+ * Mirrors team-settings.tsx. It has been wrong before by drifting: this helper
+ * kept deciding from the set of technician user ids long after the component
+ * had moved to comparing role ids, so it went on passing while the component
+ * shipped the null bug below.
  */
 function selectedValue(
   member: { user: { id: string }; role: string; roleId: string | null },
-  technicians: Set<string>,
+  technicianRoleId: string | null,
   roles: { id: string; name: string }[]
 ): string {
-  if (technicians.has(member.user.id)) return 'technician'
+  const isTechnicianRole = technicianRoleId !== null && member.roleId === technicianRoleId
+  if (isTechnicianRole) return 'technician'
   if (roles.some((r) => r.id === member.roleId && r.name !== TECHNICIAN_ROLE_NAME)) {
     return member.roleId as string
   }
@@ -29,6 +33,7 @@ describe('the role dropdown value', () => {
     { id: 'role-tech', name: TECHNICIAN_ROLE_NAME },
     { id: 'role-desk', name: 'Front desk' },
   ]
+  const techId = roles.find((r) => r.name === TECHNICIAN_ROLE_NAME)?.id ?? null
   const options = [
     ...OFFERED,
     ...roles.filter((r) => r.name !== TECHNICIAN_ROLE_NAME).map((r) => r.id),
@@ -44,32 +49,50 @@ describe('the role dropdown value', () => {
       { user: { id: 'u5' }, role: 'member', roleId: 'role-gone' },
     ]
     for (const member of cases) {
-      const value = selectedValue(member, new Set(['u1']), roles)
+      const value = selectedValue(member, techId, roles)
       expect(options, `${member.user.id} selected "${value}"`).toContain(value)
     }
   })
 
-  it('shows Technician for somebody on the board', () => {
+  it('shows Technician for somebody holding the technician role', () => {
     expect(
-      selectedValue(
-        { user: { id: 'u1' }, role: 'member', roleId: 'role-tech' },
-        new Set(['u1']),
-        roles
-      )
+      selectedValue({ user: { id: 'u1' }, role: 'member', roleId: 'role-tech' }, techId, roles)
     ).toBe('technician')
   })
 
-  it('does not fall through to a role id the list hides', () => {
-    // The bug: a technician missing from the set fell through to role-tech,
-    // which is filtered out of the options, so the trigger rendered empty.
-    expect(
-      selectedValue({ user: { id: 'u1' }, role: 'member', roleId: 'role-tech' }, new Set(), roles)
-    ).toBe('member')
-  })
-
   it('keeps a real custom role selected', () => {
     expect(
-      selectedValue({ user: { id: 'u2' }, role: 'member', roleId: 'role-desk' }, new Set(), roles)
+      selectedValue({ user: { id: 'u2' }, role: 'member', roleId: 'role-desk' }, techId, roles)
     ).toBe('role-desk')
   })
+
+  /**
+   * A workshop that has never added a technician has no technician role, so
+   * the id to compare against is null. An ordinary member with no custom role
+   * has a null roleId too, and comparing the two directly made null === null
+   * true: every plain member rendered as Technician, and setting them back to
+   * Member wrote the null already there, so the save succeeded and the
+   * dropdown never moved.
+   */
+  describe('in a workshop with no technician role yet', () => {
+    const fresh = [{ id: 'role-desk', name: 'Front desk' }]
+
+    it('does not call a plain member a technician', () => {
+      expect(selectedValue({ user: { id: 'u4' }, role: 'member', roleId: null }, null, fresh)).toBe(
+        'member'
+      )
+    })
+
+    it('does not call an admin a technician', () => {
+      expect(selectedValue({ user: { id: 'u3' }, role: 'admin', roleId: null }, null, fresh)).toBe(
+        'admin'
+      )
+    })
+
+    it('still resolves a custom role', () => {
+      expect(
+        selectedValue({ user: { id: 'u2' }, role: 'member', roleId: 'role-desk' }, null, fresh)
+      ).toBe('role-desk')
+    })
+  })
 })

+ 44 - 7
src/__tests__/i18n/keys-resolve.test.ts

@@ -24,13 +24,17 @@ for (const file of fs.readdirSync(MESSAGES)) {
   }
 }
 
-function resolves(dotted: string): boolean {
+function lookup(dotted: string): unknown {
   let node: unknown = bundle
   for (const part of dotted.split('.')) {
-    if (node == null || typeof node !== 'object' || !(part in (node as object))) return false
+    if (node == null || typeof node !== 'object' || !(part in (node as object))) return undefined
     node = (node as Record<string, unknown>)[part]
   }
-  return typeof node === 'string'
+  return node
+}
+
+function resolves(dotted: string): boolean {
+  return typeof lookup(dotted) === 'string'
 }
 
 function sourceFiles(dir: string, out: string[] = []): string[] {
@@ -53,8 +57,8 @@ function sourceFiles(dir: string, out: string[] = []): string[] {
  * than guessed at. Skipping loses coverage; guessing invents failures, and a
  * test nobody trusts gets deleted.
  */
-function requestedKeys(): { file: string; key: string }[] {
-  const found: { file: string; key: string }[] = []
+function requestedKeys(): { file: string; key: string; hasArgs: boolean }[] {
+  const found: { file: string; key: string; hasArgs: boolean }[] = []
 
   for (const file of sourceFiles(path.join(ROOT, 'src'))) {
     const src = fs.readFileSync(file, 'utf-8')
@@ -70,12 +74,21 @@ function requestedKeys(): { file: string; key: string }[] {
 
     for (const [binding, namespace] of namespaces) {
       if (counts.get(binding) !== 1) continue
-      const call = new RegExp(`\\b${binding}(?:\\.rich|\\.raw)?\\(\\s*['"]([\\w.]+)['"]`, 'g')
+      // The trailing group tells us whether anything followed the key, which
+      // is what says the call passed values for the message's placeholders.
+      const call = new RegExp(
+        `\\b${binding}(?:\\.rich|\\.raw)?\\(\\s*['"]([\\w.]+)['"]\\s*(,?)`,
+        'g'
+      )
       for (const m of src.matchAll(call)) {
         // `t(`prefix.${code}`)` leaves a trailing dot on the literal part.
         // The key is assembled at runtime and there is nothing to check.
         if (m[1].endsWith('.')) continue
-        found.push({ file: path.relative(ROOT, file), key: `${namespace}.${m[1]}` })
+        found.push({
+          file: path.relative(ROOT, file),
+          key: `${namespace}.${m[1]}`,
+          hasArgs: m[2] === ',',
+        })
       }
     }
   }
@@ -89,6 +102,30 @@ describe('translation keys the code asks for', () => {
     expect(REQUESTED.length).toBeGreaterThan(200)
   })
 
+  it('are given the values their message asks for', () => {
+    /**
+     * next-intl throws at render time, not build time, when a message has a
+     * placeholder and the call site passes nothing. That is a blank page in
+     * production from a missing second argument, and neither tsc nor the lint
+     * sees it: `t('team.removeBoardOnlyTitle')` against "Take {name} off the
+     * board?" shipped exactly that.
+     *
+     * Only flags a message with placeholders called with no arguments at all.
+     * Checking each name against the object would mean parsing the call, and a
+     * test that guesses invents failures.
+     */
+    const unformatted = REQUESTED.filter((r) => {
+      if (r.hasArgs) return false
+      const message = lookup(r.key)
+      return typeof message === 'string' && /\{\s*\w+/.test(message)
+    })
+    const lines = [...new Set(unformatted.map((r) => `${r.key}  (${r.file})`))].sort()
+    expect(
+      lines,
+      `These messages take a value the call site never passes:\n  ${lines.join('\n  ')}`
+    ).toEqual([])
+  })
+
   it('all resolve to a string in English', () => {
     const missing = [...new Set(REQUESTED.filter((r) => !resolves(r.key)).map((r) => r.key))].sort()
     expect(

+ 266 - 94
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Checkbox } from '@/components/ui/checkbox'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 import {
   Select,
   SelectContent,
@@ -26,11 +27,19 @@ import { createRole } from '@/features/team/Actions/createRole'
 import { updateRole } from '@/features/team/Actions/updateRole'
 import { deleteRole } from '@/features/team/Actions/deleteRole'
 import { assignRole } from '@/features/team/Actions/assignRole'
+import { setMemberTechnician } from '@/features/team/Actions/setMemberTechnician'
+import { createDefaultRoles } from '@/features/team/Actions/createDefaultRoles'
 import { AddPersonDialog } from '@/features/team/Components/AddPersonDialog'
-import { contactFor, TECHNICIAN_ROLE_NAME } from '@/features/team/Lib/technicianRole'
+import {
+  contactFor,
+  MEMBER_ROLE_NAME,
+  TECHNICIAN_PERMISSIONS,
+  TECHNICIAN_ROLE_NAME,
+} from '@/features/team/Lib/technicianRole'
 import { AppSetupCodeDialog } from '@/features/team/Components/AppSetupCodeDialog'
 import { GiveAppDialog } from '@/features/team/Components/GiveAppDialog'
 import { removeTechnicianAccess } from '@/features/team/Actions/removeTechnicianAccess'
+import { deleteTechnician } from '@/features/workboard/Actions/technicianActions'
 import { permissionGroups, PermissionAction } from '@/lib/permissions'
 
 /**
@@ -52,6 +61,7 @@ import {
   Copy,
   Crown,
   Loader2,
+  LogOut,
   Mail,
   Pencil,
   Plus,
@@ -157,6 +167,18 @@ export function TeamSettings({
   const technicians = useMemo(() => new Set(technicianUserIds), [technicianUserIds])
   /** The workshop's technician role, if it has one yet. */
   const technicianRoleId = roles.find((r) => r.name === TECHNICIAN_ROLE_NAME)?.id ?? null
+  /**
+   * Whether a member holds the technician role.
+   *
+   * The null check is the whole point. A workshop that has never added a
+   * technician has no technician role, so `technicianRoleId` is null, and an
+   * ordinary member with no custom role has a null `roleId` too. Comparing the
+   * two directly made `null === null` true, so every plain member rendered as
+   * Technician, and setting them back to Member wrote the null that was
+   * already there: the save succeeded and the dropdown never moved.
+   */
+  const isTechnicianRole = (roleId: string | null | undefined) =>
+    technicianRoleId !== null && roleId === technicianRoleId
   /** The member whose app is being set up, and their name for the copy. */
   const [settingUp, setSettingUp] = useState<{ userId: string; name: string } | null>(null)
   const [adding, setAdding] = useState(startAdding)
@@ -263,35 +285,31 @@ export function TeamSettings({
     }
   }
 
-  const handleAssignRole = async (memberId: string, value: string, member?: Member) => {
-    let role: 'admin' | 'member' | 'technician'
+  /**
+   * Changes what somebody may do. Never what they work on.
+   *
+   * This used to revoke the technician app as a side effect of a role change,
+   * behind a confirmation, because role and board membership were the same
+   * decision. They are two now: the switch beside the dropdown owns the board,
+   * and it carries that confirmation.
+   */
+  const handleAssignRole = async (memberId: string, value: string) => {
+    let role: 'admin' | 'member'
     let roleId: string | null
 
-    if (value === 'admin' || value === 'member' || value === 'technician') {
-      role = value
+    if (value === 'admin') {
+      role = 'admin'
+      roleId = null
+    } else if (value === 'none') {
+      // A real state, and the one that refuses every screen, so it is offered
+      // by name rather than reached by accident.
+      role = 'member'
       roleId = null
     } else {
-      // Custom role ID
       role = 'member'
       roleId = value
     }
 
-    // Moving somebody off Technician takes their phone with them, which is not
-    // something to discover from a dropdown.
-    const wasTechnician = member ? technicians.has(member.user.id) : false
-    if (wasTechnician && value !== 'technician') {
-      const ok = await confirm({
-        title: t('team.revokeTechnicianTitle'),
-        description: t('team.revokeTechnicianBody', {
-          name: member?.user.name || member?.user.email || '',
-        }),
-        confirmLabel: t('team.revokeTechnicianConfirm'),
-        destructive: true,
-      })
-      if (!ok) return
-      if (member) await removeTechnicianAccess({ userId: member.user.id })
-    }
-
     const result = await assignRole({ memberId, role, roleId })
     if (result.success) {
       toast.success(t('team.roleAssigned'))
@@ -301,6 +319,93 @@ export function TeamSettings({
     }
   }
 
+  /**
+   * Whether this person's role can actually work a job from the phone.
+   *
+   * The app enforces the same permissions as the web, so an account whose role
+   * does not carry them signs in and is refused by every screen. Answering the
+   * question here means the button can say so instead of handing somebody a
+   * setup code that leads nowhere.
+   */
+  const canUseApp = (member: Member) => {
+    if (member.role === 'owner' || member.role === 'admin') return true
+    const role = roles.find((r) => r.id === member.roleId)
+    if (!role) return false
+    if (role.isAdmin) return true
+    return TECHNICIAN_PERMISSIONS.every((needed) =>
+      role.permissions.some((p) => p.action === needed.action && p.subject === needed.subject)
+    )
+  }
+
+  /**
+   * Puts the app on somebody's phone.
+   *
+   * Being on the board is part of having the app rather than a separate
+   * switch beside it: the API refuses anybody without a technician record, so
+   * a code issued to somebody who has none is a code that cannot be redeemed.
+   * This makes the record first if it is missing, then hands over the code.
+   */
+  const handleSetUpApp = async (member: Member) => {
+    if (!technicians.has(member.user.id)) {
+      const result = await setMemberTechnician({ userId: member.user.id, enabled: true })
+      if (!result.success) {
+        modal.open('error', 'Error', result.error || t('team.technicianFailed'))
+        return
+      }
+      router.refresh()
+    }
+    setSettingUp({ userId: member.user.id, name: member.user.name || member.user.email })
+  }
+
+  /**
+   * Takes the app back off somebody's phone, and them off the board.
+   *
+   * Deactivates rather than deletes: past jobs, inspections and clocked hours
+   * all point at the technician record, and removing it would rewrite history
+   * to say nobody did the work.
+   */
+  const handleRevokeApp = async (member: Member) => {
+    const ok = await confirm({
+      title: t('team.revokeTechnicianTitle'),
+      description: t('team.revokeTechnicianBody', {
+        name: member.user.name || member.user.email,
+      }),
+      confirmLabel: t('team.revokeTechnicianConfirm'),
+      destructive: true,
+    })
+    if (!ok) return
+
+    await removeTechnicianAccess({ userId: member.user.id })
+    const result = await setMemberTechnician({ userId: member.user.id, enabled: false })
+    if (result.success) {
+      toast.success(t('team.revokeTechnicianDone', { name: member.user.name || member.user.email }))
+      router.refresh()
+    } else {
+      modal.open('error', 'Error', result.error || t('team.technicianFailed'))
+    }
+  }
+
+  /**
+   * True while the workshop is missing one of the two roles nearly every
+   * workshop wants. Nothing is created automatically: an install that has
+   * carefully narrowed its own roles should not find two more appearing.
+   */
+  const missingDefaultRoles =
+    !roles.some((r) => r.name === MEMBER_ROLE_NAME) ||
+    !roles.some((r) => r.name === TECHNICIAN_ROLE_NAME)
+
+  const handleCreateDefaultRoles = async () => {
+    setLoading(true)
+    const result = await createDefaultRoles()
+    if (result.success) {
+      toast.success(t('team.defaultRolesCreated'))
+      router.refresh()
+    } else {
+      modal.open('error', 'Error', result.error || t('team.failedSaveRole'))
+    }
+    setLoading(false)
+  }
+
   const handleCreateOrg = async () => {
     if (!orgName.trim()) return
     setLoading(true)
@@ -348,6 +453,32 @@ export function TeamSettings({
     }
   }
 
+  /**
+   * Takes a board-only technician off the board.
+   *
+   * The same action the work board uses, rather than a second path: it
+   * deactivates rather than deletes, because TimeEntry cascades from
+   * Technician and a hard delete would take every hour they ever clocked.
+   * Adding somebody happens here, so removing them should too, instead of
+   * sending people to a scheduling screen to finish the job.
+   */
+  const handleRemoveStandalone = async (tech: { id: string; name: string }) => {
+    const ok = await confirm({
+      title: t('team.removeBoardOnlyTitle', { name: tech.name }),
+      description: t('team.removeBoardOnlyBody', { name: tech.name }),
+      confirmLabel: t('team.removeButton'),
+      destructive: true,
+    })
+    if (!ok) return
+    const result = await deleteTechnician(tech.id)
+    if (result.success) {
+      toast.success(t('team.removeBoardOnlyDone', { name: tech.name }))
+      router.refresh()
+    } else {
+      modal.open('error', 'Error', result.error || t('team.failedRemoveMember'))
+    }
+  }
+
   if (!organization) {
     return (
       <div className="space-y-6">
@@ -422,79 +553,91 @@ export function TeamSettings({
                 <p className="truncate text-xs text-muted-foreground">{contactFor(member.user)}</p>
               </div>
               <div className="flex items-center gap-2">
-                {technicians.has(member.user.id) && member.roleId !== technicianRoleId && (
+                {technicians.has(member.user.id) && (
                   <Badge variant="outline" className="text-xs">
                     <Wrench className="mr-1 h-3 w-3" />
-                    {t('team.technician')}
+                    {t('team.worksOnJobs')}
                   </Badge>
                 )}
-                {isAdmin && technicians.has(member.user.id) && (
-                  <Button
-                    variant="ghost"
-                    size="icon"
-                    className="h-8 w-8 text-muted-foreground hover:text-foreground"
-                    onClick={() =>
-                      setSettingUp({
-                        userId: member.user.id,
-                        name: member.user.name || member.user.email,
-                      })
-                    }
-                    aria-label={t('team.setupApp')}
-                    title={t('team.setupApp')}
-                  >
-                    <Smartphone className="h-4 w-4" />
-                  </Button>
+                {/* The way back off. Setting somebody up and signing them out
+                    are both one click, and neither hides inside the other. */}
+                {isAdmin && member.role !== 'owner' && technicians.has(member.user.id) && (
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-8 w-8 text-muted-foreground hover:text-destructive"
+                        onClick={() => handleRevokeApp(member)}
+                        aria-label={t('team.revokeTechnicianTitle')}
+                      >
+                        <LogOut className="h-4 w-4" />
+                      </Button>
+                    </TooltipTrigger>
+                    <TooltipContent>{t('team.revokeTechnicianTitle')}</TooltipContent>
+                  </Tooltip>
+                )}
+                {isAdmin && member.role !== 'owner' && (
+                  /* Shown for everybody, because "can this person have the
+                     app" is a question about their role, and hiding the
+                     button left no way to find out the answer. Disabled with
+                     the reason rather than absent. */
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <span>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          className="h-8 w-8 text-muted-foreground hover:text-foreground"
+                          disabled={!canUseApp(member)}
+                          onClick={() => handleSetUpApp(member)}
+                          aria-label={t('team.setupApp')}
+                        >
+                          <Smartphone className="h-4 w-4" />
+                        </Button>
+                      </span>
+                    </TooltipTrigger>
+                    <TooltipContent>
+                      {canUseApp(member) ? t('team.setupApp') : t('team.cannotUseApp')}
+                    </TooltipContent>
+                  </Tooltip>
                 )}
                 {isOwner && member.role !== 'owner' ? (
                   <Select
                     value={
-                      // Read from the role they hold, not from whether they
-                      // are on the board.
-                      //
-                      // An install that predates this has technicians holding
-                      // all sorts of roles. Showing Technician for them would
-                      // both misreport what they can do and overwrite it the
-                      // moment anybody touched the field. Their real role shows
-                      // here; the badge beside it says they are also on the
-                      // board.
-                      member.roleId === technicianRoleId
-                        ? 'technician'
-                        : // Never a value with no option behind it, or the
-                          // trigger renders empty and the member looks
-                          // roleless when they are not.
-                          roles.some(
-                              (r) => r.id === member.roleId && r.name !== TECHNICIAN_ROLE_NAME
-                            )
+                      /**
+                       * Only ever a value the list actually offers.
+                       *
+                       * The list used to mix Better Auth's membership tiers
+                       * with this product's roles, so "Member" sat beside real
+                       * permission sets while granting nothing at all. Now it
+                       * offers Admin, every role the workshop has, and an
+                       * explicit "No role" for somebody who genuinely has
+                       * none, which is a state that has to be nameable
+                       * because it is the one that refuses every screen.
+                       */
+                      member.role === 'admin'
+                        ? 'admin'
+                        : roles.some((r) => r.id === member.roleId)
                           ? (member.roleId as string)
-                          : member.role
+                          : 'none'
                     }
-                    onValueChange={(v) => handleAssignRole(member.id, v, member)}
+                    onValueChange={(v) => handleAssignRole(member.id, v)}
                   >
-                    <SelectTrigger className="h-8 w-36 text-xs">
+                    <SelectTrigger className="h-8 w-40 text-xs">
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>
+                      {/* A real tier: it short-circuits every permission
+                          check, so it grants something on its own. */}
                       <SelectItem value="admin">{t('team.admin')}</SelectItem>
-                      <SelectItem value="member">{t('team.member')}</SelectItem>
-                      {/* One answer to one question. Choosing this puts them on
-                          the work board and gives them what the app needs;
-                          choosing anything else takes both away. */}
-                      <SelectItem value="technician">{t('team.technician')}</SelectItem>
-                      {roles.length > 0 && (
-                        <>
-                          <SelectSeparator />
-                          {roles
-                            // The technician permissions are what the dropdown
-                            // entry above grants, so offering the role again
-                            // underneath is the same choice listed twice.
-                            .filter((r) => r.name !== TECHNICIAN_ROLE_NAME)
-                            .map((r) => (
-                              <SelectItem key={r.id} value={r.id}>
-                                {r.name}
-                              </SelectItem>
-                            ))}
-                        </>
-                      )}
+                      {roles.map((r) => (
+                        <SelectItem key={r.id} value={r.id}>
+                          {r.name}
+                        </SelectItem>
+                      ))}
+                      <SelectSeparator />
+                      <SelectItem value="none">{t('team.noRole')}</SelectItem>
                     </SelectContent>
                   </Select>
                 ) : (
@@ -540,14 +683,25 @@ export function TeamSettings({
                   </p>
                 </div>
                 {isAdmin && (
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    onClick={() => setGivingApp({ id: tech.id, name: tech.name })}
-                  >
-                    <Smartphone className="mr-1 h-4 w-4" />
-                    {t('team.giveApp')}
-                  </Button>
+                  <>
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      onClick={() => setGivingApp({ id: tech.id, name: tech.name })}
+                    >
+                      <Smartphone className="mr-1 h-4 w-4" />
+                      {t('team.giveApp')}
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      aria-label={t('team.removeButton')}
+                      className="text-muted-foreground hover:text-destructive"
+                      onClick={() => handleRemoveStandalone(tech)}
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </>
                 )}
               </div>
             ))}
@@ -629,10 +783,25 @@ export function TeamSettings({
           title={t('team.customRoles')}
           action={
             !showRoleForm && (
-              <Button size="sm" variant="outline" onClick={() => openRoleForm()}>
-                <Plus className="mr-1 h-4 w-4" />
-                {t('team.newRole')}
-              </Button>
+              <div className="flex gap-2">
+                {/* Only while one of them is missing. A workshop that has both,
+                    or has renamed them, is left alone. */}
+                {missingDefaultRoles && (
+                  <Button
+                    size="sm"
+                    variant="outline"
+                    onClick={handleCreateDefaultRoles}
+                    disabled={loading}
+                  >
+                    <ShieldCheck className="mr-1 h-4 w-4" />
+                    {t('team.createDefaultRoles')}
+                  </Button>
+                )}
+                <Button size="sm" variant="outline" onClick={() => openRoleForm()}>
+                  <Plus className="mr-1 h-4 w-4" />
+                  {t('team.newRole')}
+                </Button>
+              </div>
             )
           }
           contentClassName="space-y-4"
@@ -843,11 +1012,14 @@ export function TeamSettings({
             </Badge>
             <span className="text-muted-foreground">{t('team.adminDescription')}</span>
           </div>
+          {/* Not "Member". That was Better Auth's membership tier listed as
+              though it were a role, while granting nothing at all. What it
+              actually describes is the absence of a role, so it says so. */}
           <div className="flex items-center gap-3">
             <Badge variant="outline" className={`${roleColors.member}`}>
-              <User className="mr-1 h-3 w-3" /> {t('team.memberLabel')}
+              <User className="mr-1 h-3 w-3" /> {t('team.noRole')}
             </Badge>
-            <span className="text-muted-foreground">{t('team.memberDescription')}</span>
+            <span className="text-muted-foreground">{t('team.noRoleDescription')}</span>
           </div>
         </div>
       </AppCard>

+ 33 - 12
src/features/team/Actions/assignRole.ts

@@ -6,7 +6,11 @@ import { assignRoleSchema } from '../Schema/teamSchema'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { setTechnicianStanding } from '../Lib/technicianStanding'
-import { ensureTechnicianRole, TECHNICIAN_ROLE_VALUE } from '../Lib/technicianRole'
+import {
+  ensureTechnicianRole,
+  TECHNICIAN_ROLE_NAME,
+  TECHNICIAN_ROLE_VALUE,
+} from '../Lib/technicianRole'
 
 export async function assignRole(input: unknown) {
   return withAuth(
@@ -40,26 +44,43 @@ export async function assignRole(input: unknown) {
        * the technician record the work board and the app read, and the
        * permissions the API checks.
        */
-      const asTechnician = data.role === TECHNICIAN_ROLE_VALUE
+      /**
+       * The role decides what they may do. Whether they work jobs is the
+       * switch beside it, and this no longer answers that question for them.
+       *
+       * It used to: choosing any role other than Technician deactivated their
+       * technician record, so a desk person who also turns spanners could not
+       * exist. You could have the permissions to run the office or a place on
+       * the board, never both, and the only account that escaped it was the
+       * owner, because this action refuses to touch an owner at all.
+       *
+       * Now it is one way. Picking the technician role puts them on the board,
+       * because that is plainly what was meant. Picking anything else leaves
+       * the board alone.
+       */
+      const technicianRole = await db.role.findFirst({
+        where: { organizationId, name: TECHNICIAN_ROLE_NAME },
+        select: { id: true },
+      })
+      // `technician` as a role word is kept for callers that still send it,
+      // though the team page now picks the role by id like any other.
+      const byWord = data.role === TECHNICIAN_ROLE_VALUE
+      const byId = Boolean(data.roleId && data.roleId === technicianRole?.id)
+      const asTechnician = byWord || byId
 
       await db.organizationMember.update({
         where: { id: data.memberId },
         data: {
           // `technician` is ours, not one of Better Auth's built-in three, so
           // the stored role stays `member` and the custom role carries it.
-          ...(data.role && { role: asTechnician ? 'member' : data.role }),
-          roleId: asTechnician
-            ? await ensureTechnicianRole(db, organizationId)
-            : (data.roleId ?? null),
+          ...(data.role && { role: byWord ? 'member' : data.role }),
+          roleId: byWord ? await ensureTechnicianRole(db, organizationId) : (data.roleId ?? null),
         },
       })
 
-      const technician = await setTechnicianStanding(
-        organizationId,
-        member.userId,
-        asTechnician,
-        member.userId
-      )
+      const technician = asTechnician
+        ? await setTechnicianStanding(organizationId, member.userId, true, member.userId)
+        : null
 
       revalidatePath('/settings/team')
       revalidatePath('/work-board')

+ 54 - 0
src/features/team/Actions/createDefaultRoles.ts

@@ -0,0 +1,54 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import {
+  ensureMemberRole,
+  ensureTechnicianRole,
+  MEMBER_ROLE_NAME,
+  TECHNICIAN_ROLE_NAME,
+} from '../Lib/technicianRole'
+
+/**
+ * Gives a workshop the two roles it almost certainly wants.
+ *
+ * Nothing seeds roles when an organization is created, so every workshop
+ * started with none and the word "Member" in the role dropdown granted
+ * nothing at all: `member` is Better Auth's membership tier, not a permission
+ * set. A member with no role has no permissions and is refused by every
+ * screen, which is a promise the dropdown was not keeping.
+ *
+ * Made on demand rather than in a migration, so existing workshops get them
+ * by pressing a button and nobody's carefully narrowed roles are touched.
+ * Both are ordinary roles afterwards: renameable, editable, deletable.
+ */
+export async function createDefaultRoles() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const before = await db.role.findMany({
+        where: { organizationId, name: { in: [MEMBER_ROLE_NAME, TECHNICIAN_ROLE_NAME] } },
+        select: { name: true },
+      })
+      const had = new Set(before.map((r) => r.name))
+
+      await ensureMemberRole(db, organizationId)
+      await ensureTechnicianRole(db, organizationId)
+
+      const created = [MEMBER_ROLE_NAME, TECHNICIAN_ROLE_NAME].filter((n) => !had.has(n))
+      revalidatePath('/settings/team')
+      return { created }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'team.createDefaultRoles',
+        message: 'Created the default roles',
+        metadata: { created: result.created },
+      }),
+    }
+  )
+}

+ 47 - 16
src/features/team/Components/AddPersonDialog.tsx

@@ -40,6 +40,9 @@ import { sendInvitation } from '@/features/team/Actions/sendInvitation'
 import { inviteMember } from '@/features/team/Actions/teamActions'
 import { countriesFor } from '@/features/team/Lib/dialCodes'
 import { TECHNICIAN_ROLE_NAME } from '@/features/team/Lib/technicianRole'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { createDefaultRoles } from '@/features/team/Actions/createDefaultRoles'
 import { CountryPicker } from './CountryPicker'
 import { useTechnicianConnected } from '@/features/team/hooks/useTechnicianConnected'
 import { type IssuedCode, SetupCodeHandoff } from './SetupCodeHandoff'
@@ -148,7 +151,15 @@ export function AddPersonDialog({
   }, [open, fetched, dialCodeProp, rolesProp])
 
   const dialCode = dialCodeProp ?? fetched?.dialCode ?? ''
+  const router = useRouter()
   const roles = rolesProp ?? fetched?.roles ?? []
+  /**
+   * The roles this step may offer.
+   *
+   * Technicians are added through the other door, so offering their role here
+   * is a path to an account with app permissions and no phone to use them on.
+   */
+  const deskRoles = roles.filter((r) => r.name !== TECHNICIAN_ROLE_NAME)
   // Configured address first; the current origin is right in production and
   // is localhost in development, which a technician's phone cannot reach.
   const workshopUrl =
@@ -565,24 +576,44 @@ export function AddPersonDialog({
                 </SelectTrigger>
                 <SelectContent>
                   <SelectItem value="admin">{t('team.admin')}</SelectItem>
-                  <SelectItem value="member">{t('team.member')}</SelectItem>
-                  {roles.length > 0 && (
-                    <>
-                      <SelectSeparator />
-                      {roles
-                        // Technicians are added through the other door, so
-                        // offering their role here is a path to an account
-                        // with app permissions and no phone to use them on.
-                        .filter((r) => r.name !== TECHNICIAN_ROLE_NAME)
-                        .map((r) => (
-                          <SelectItem key={r.id} value={r.id}>
-                            {r.name}
-                          </SelectItem>
-                        ))}
-                    </>
-                  )}
+                  {deskRoles.map((r) => (
+                    <SelectItem key={r.id} value={r.id}>
+                      {r.name}
+                    </SelectItem>
+                  ))}
+                  <SelectSeparator />
+                  <SelectItem value="none">{t('team.noRole')}</SelectItem>
                 </SelectContent>
               </Select>
+              {/* A workshop with no roles has nothing to give this person
+                  beyond Admin, and an invite that grants nothing is a person
+                  who signs in and finds every screen refuses them. Offer the
+                  standard desk role rather than sending them off to build one
+                  before they can finish adding somebody. */}
+              {deskRoles.length === 0 && (
+                <div className="rounded-md border border-dashed p-3 text-xs">
+                  <p className="text-muted-foreground">{t('team.noRolesYet')}</p>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="mt-2"
+                    disabled={busy}
+                    onClick={async () => {
+                      setBusy(true)
+                      const result = await createDefaultRoles()
+                      setBusy(false)
+                      if (result.success) {
+                        toast.success(t('team.defaultRolesCreated'))
+                        onChanged?.()
+                        router.refresh()
+                      }
+                    }}
+                  >
+                    {t('team.createDefaultRoles')}
+                  </Button>
+                </div>
+              )}
               {/* Which of the two they should pick, in one line, without the
                   word "permissions". */}
               <p className="text-muted-foreground text-xs">{t('team.roleHint')}</p>

+ 67 - 0
src/features/team/Lib/technicianRole.ts

@@ -30,6 +30,45 @@ export const TECHNICIAN_PERMISSIONS = [
  * called Technician, meaning different things, which is a sentence nobody
  * should have to read twice.
  */
+/**
+ * What somebody at the desk needs to run the day, expressed once.
+ *
+ * `member` is Better Auth's membership tier, not a permission set: a member
+ * with no role has `granted = []` and is refused by every screen, which made
+ * the word in the role dropdown a promise the product did not keep. This is
+ * the role that word should have meant.
+ *
+ * Deliberately short of Settings, Billing and Reports. Those are the owner's
+ * business, and a role that quietly included them would be worse than one that
+ * is easy to widen by hand.
+ */
+export const MEMBER_PERMISSIONS = [
+  { action: PermissionAction.READ, subject: PermissionSubject.DASHBOARD },
+  // The day's work: booking cars in, writing jobs up, taking payment.
+  { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+  { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
+  { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+  { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES },
+  { action: PermissionAction.CREATE, subject: PermissionSubject.VEHICLES },
+  { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+  { action: PermissionAction.READ, subject: PermissionSubject.WORK_ORDERS },
+  { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_ORDERS },
+  { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_ORDERS },
+  { action: PermissionAction.READ, subject: PermissionSubject.SERVICES },
+  { action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES },
+  { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+  { action: PermissionAction.READ, subject: PermissionSubject.QUOTES },
+  { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES },
+  { action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES },
+  // Looking parts up and seeing the week. Neither changes anything on its own.
+  { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+  { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
+  { action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS },
+] as const
+
+/** The name the seeded desk role carries. */
+export const MEMBER_ROLE_NAME = 'Member'
+
 export const TECHNICIAN_ROLE_NAME = 'Technician'
 
 /** The value the role dropdown uses for it, alongside `admin` and `member`. */
@@ -44,6 +83,34 @@ type Tx = Pick<PrismaClient, 'role'>
  * the account can reach behind a flag, which is the exact shape of the bug
  * this product just finished removing.
  */
+/**
+ * The workshop's desk role, made once and reused after that.
+ *
+ * Same contract as the technician one below: created on demand rather than at
+ * signup, so an install that predates it gains the role the first time anybody
+ * asks for it instead of needing a migration.
+ */
+export async function ensureMemberRole(tx: Tx, organizationId: string): Promise<string> {
+  const existing = await tx.role.findFirst({
+    where: { organizationId, name: MEMBER_ROLE_NAME },
+    select: { id: true },
+  })
+  if (existing) return existing.id
+
+  const created = await tx.role.create({
+    data: {
+      name: MEMBER_ROLE_NAME,
+      organizationId,
+      isAdmin: false,
+      permissions: {
+        create: MEMBER_PERMISSIONS.map((p) => ({ action: p.action, subject: p.subject })),
+      },
+    },
+    select: { id: true },
+  })
+  return created.id
+}
+
 export async function ensureTechnicianRole(tx: Tx, organizationId: string): Promise<string> {
   const existing = await tx.role.findFirst({
     where: { organizationId, name: TECHNICIAN_ROLE_NAME },

+ 3 - 0
src/features/workboard/Actions/technicianActions.ts

@@ -176,6 +176,9 @@ export async function deleteTechnician(id: string) {
       })
 
       revalidatePath('/work-board')
+      // Board-only technicians are listed on the team page too, and that is now
+      // where they can be removed from.
+      revalidatePath('/settings/team')
       return { success: true, technicianId: id }
     },
     {