feat(sprint-5): Phase 6 — Staff management UI (list, invite, permissions, revoke)

- /settings/staff: staff account table with role badges + permission chips
- Invite sheet: email + role template + 8 granular permission checkboxes
- Edit permissions dialog with optimistic update
- Revoke access with AlertDialog confirmation
- React Query hooks wired (useStaffListQuery, mutations)
- Full i18n (de/en), mock fallback, loading skeletons
- Sidebar nav updated: Personal → /settings/staff with UserCog icon
- Added @radix-ui/react-checkbox + Checkbox UI component
This commit is contained in:
Patrick Plate
2026-06-12 20:32:54 +02:00
parent ed1efccc90
commit 2cc8c89944
9 changed files with 941 additions and 3 deletions
+39
View File
@@ -293,6 +293,45 @@
"footerText": "Cannabis-Anbauverein — Sichere Mitgliederverwaltung",
"adminLogin": "Zum Admin-Login"
},
"staff": {
"title": "Personal",
"invite": "Neues Mitglied einladen",
"name": "Name",
"email": "E-Mail",
"role": "Rolle",
"permissions": "Berechtigungen",
"status": "Status",
"actions": "Aktionen",
"active": "Aktiv",
"revoked": "Widerrufen",
"invited": "Eingeladen",
"editPermissions": "Berechtigungen bearbeiten",
"revokeAccess": "Zugang widerrufen",
"revokeConfirm": "Zugang für {name} wirklich widerrufen? Der Mitarbeiter kann sich nicht mehr anmelden.",
"revokeSuccess": "Zugang widerrufen.",
"inviteTitle": "Mitarbeiter einladen",
"inviteDesc": "Neues Teammitglied per E-Mail einladen und Berechtigungen zuweisen.",
"inviteEmail": "E-Mail-Adresse",
"inviteRole": "Rollenvorlage",
"roleAusgabe": "Ausgabe",
"roleLager": "Lager",
"roleVorstand": "Vorstand",
"roleCustom": "Benutzerdefiniert",
"inviteSend": "Einladung senden",
"inviteSuccess": "Einladung an {email} gesendet.",
"permRecordDistribution": "Ausgabe erfassen",
"permViewMemberList": "Mitgliederliste einsehen",
"permViewMemberQuota": "Kontingent einsehen",
"permAddMember": "Mitglieder anlegen",
"permViewStock": "Lager einsehen",
"permRecordStockIn": "Wareneingang",
"permViewComplianceReport": "Berichte einsehen",
"permManageGrowCalendar": "Anbaukalender verwalten",
"savePermissions": "Speichern",
"permissionsSaved": "Berechtigungen aktualisiert.",
"cancel": "Abbrechen",
"noStaff": "Noch keine Mitarbeiter vorhanden."
},
"api": {
"loading": "Wird geladen...",
"error": "Fehler beim Laden der Daten.",
+39
View File
@@ -293,6 +293,45 @@
"footerText": "Cannabis cultivation club — Secure member management",
"adminLogin": "Go to Admin Login"
},
"staff": {
"title": "Staff",
"invite": "Invite New Member",
"name": "Name",
"email": "Email",
"role": "Role",
"permissions": "Permissions",
"status": "Status",
"actions": "Actions",
"active": "Active",
"revoked": "Revoked",
"invited": "Invited",
"editPermissions": "Edit Permissions",
"revokeAccess": "Revoke Access",
"revokeConfirm": "Really revoke access for {name}? The staff member will no longer be able to sign in.",
"revokeSuccess": "Access revoked.",
"inviteTitle": "Invite Staff Member",
"inviteDesc": "Invite a new team member via email and assign permissions.",
"inviteEmail": "Email address",
"inviteRole": "Role template",
"roleAusgabe": "Distribution",
"roleLager": "Stock",
"roleVorstand": "Board",
"roleCustom": "Custom",
"inviteSend": "Send Invitation",
"inviteSuccess": "Invitation sent to {email}.",
"permRecordDistribution": "Record Distribution",
"permViewMemberList": "View Member List",
"permViewMemberQuota": "View Quota",
"permAddMember": "Add Members",
"permViewStock": "View Stock",
"permRecordStockIn": "Record Stock In",
"permViewComplianceReport": "View Reports",
"permManageGrowCalendar": "Manage Grow Calendar",
"savePermissions": "Save",
"permissionsSaved": "Permissions updated.",
"cancel": "Cancel",
"noStaff": "No staff members yet."
},
"api": {
"loading": "Loading...",
"error": "Failed to load data.",
+1
View File
@@ -25,6 +25,7 @@
"@hookform/resolvers": "3.9.1",
"@radix-ui/react-alert-dialog": "1.1.1",
"@radix-ui/react-avatar": "1.1.0",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "1.1.0",
"@radix-ui/react-dialog": "1.1.3",
"@radix-ui/react-direction": "1.1.0",
+206
View File
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-avatar':
specifier: 1.1.0
version: 1.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-checkbox':
specifier: ^1.3.4
version: 1.3.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-collapsible':
specifier: 1.1.0
version: 1.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
@@ -708,6 +711,9 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/primitive@1.1.4':
resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
'@radix-ui/react-alert-dialog@1.1.1':
resolution: {integrity: sha512-wmCoJwj7byuVuiLKqDLlX7ClSUU0vd9sdCeM+2Ls+uf13+cpSJoMgwysHq1SGVVkJj5Xn0XWi1NoRCdkMpr6Mw==}
peerDependencies:
@@ -760,6 +766,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.4':
resolution: {integrity: sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.0':
resolution: {integrity: sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==}
peerDependencies:
@@ -813,6 +832,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-compose-refs@1.1.3':
resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.0':
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
peerDependencies:
@@ -840,6 +868,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.4':
resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.1':
resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==}
peerDependencies:
@@ -1167,6 +1204,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.6':
resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
@@ -1219,6 +1269,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.5':
resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.0':
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
peerDependencies:
@@ -1294,6 +1357,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.5':
resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-toast@1.2.1':
resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==}
peerDependencies:
@@ -1356,6 +1428,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.3':
resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
@@ -1365,6 +1446,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.3':
resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.0':
resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==}
peerDependencies:
@@ -1401,6 +1491,24 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.2':
resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.2':
resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies:
@@ -1419,6 +1527,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.2':
resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.1.0':
resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
peerDependencies:
@@ -4084,6 +4201,8 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/primitive@1.1.4': {}
'@radix-ui/react-alert-dialog@1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4128,6 +4247,22 @@ snapshots:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-checkbox@1.3.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-context': 1.1.4(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
react-dom: 19.1.3(react@19.1.3)
optionalDependencies:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-collapsible@1.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4174,6 +4309,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-compose-refs@1.1.3(@types/react@19.0.12)(react@19.1.3)':
dependencies:
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-context@1.1.0(@types/react@19.0.12)(react@19.1.3)':
dependencies:
react: 19.1.3
@@ -4192,6 +4333,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-context@1.1.4(@types/react@19.0.12)(react@19.1.3)':
dependencies:
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-dialog@1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4532,6 +4679,15 @@ snapshots:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-presence@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
react-dom: 19.1.3(react@19.1.3)
optionalDependencies:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-primitive@2.0.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@19.0.12)(react@19.1.3)
@@ -4568,6 +4724,15 @@ snapshots:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-primitive@2.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/react-slot': 1.2.5(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
react-dom: 19.1.3(react@19.1.3)
optionalDependencies:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4639,6 +4804,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-slot@1.2.5(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-toast@1.2.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4706,6 +4878,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.0.12)(react@19.1.3)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.12)(react@19.1.3)
@@ -4713,6 +4893,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-effect-event@0.0.3(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.12)(react@19.1.3)
@@ -4739,6 +4926,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.0.12)(react@19.1.3)':
dependencies:
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-previous@1.1.2(@types/react@19.0.12)(react@19.1.3)':
dependencies:
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/rect': 1.1.0
@@ -4753,6 +4952,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-size@1.1.2(@types/react@19.0.12)(react@19.1.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.0.12)(react@19.1.3)
react: 19.1.3
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
@@ -0,0 +1,7 @@
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}
@@ -0,0 +1,561 @@
"use client"
import { useState } from "react"
import {
useInviteStaffMutation,
useRevokeStaffMutation,
useStaffListQuery,
useUpdateStaffPermissionsMutation,
} from "@/services/staff"
import { useTranslations } from "next-intl"
import { Edit, Plus, ShieldX, UserCog } from "lucide-react"
import type { InviteStaffRequest, StaffMember } from "@/services/staff"
import { mockStaffAccounts } from "@/data/mock/staff"
import { useToast } from "@/hooks/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { TableSkeleton } from "@/components/ui/data-skeleton"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// --- Permission definitions ---
const ALL_PERMISSIONS = [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
"MANAGE_GROW_CALENDAR",
] as const
type StaffPermission = (typeof ALL_PERMISSIONS)[number]
const ROLE_TEMPLATES: Record<string, StaffPermission[]> = {
ausgabe: ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
lager: ["VIEW_STOCK", "RECORD_STOCK_IN", "MANAGE_GROW_CALENDAR"],
vorstand: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
"MANAGE_GROW_CALENDAR",
],
custom: [],
}
// --- Status Badge ---
function StatusBadge({
status,
t,
}: {
status: StaffMember["status"]
t: ReturnType<typeof useTranslations>
}) {
if (status === "ACTIVE") {
return (
<Badge
variant="outline"
className="border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-400"
>
{t("active")}
</Badge>
)
}
if (status === "REVOKED") {
return (
<Badge
variant="outline"
className="border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400"
>
{t("revoked")}
</Badge>
)
}
return (
<Badge
variant="outline"
className="border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
>
{t("invited")}
</Badge>
)
}
// --- Permission Checkboxes ---
function PermissionCheckboxes({
selected,
onChange,
t,
}: {
selected: string[]
onChange: (perms: string[]) => void
t: ReturnType<typeof useTranslations>
}) {
const permLabels: Record<string, string> = {
RECORD_DISTRIBUTION: t("permRecordDistribution"),
VIEW_MEMBER_LIST: t("permViewMemberList"),
VIEW_MEMBER_QUOTA: t("permViewMemberQuota"),
ADD_MEMBER: t("permAddMember"),
VIEW_STOCK: t("permViewStock"),
RECORD_STOCK_IN: t("permRecordStockIn"),
VIEW_COMPLIANCE_REPORT: t("permViewComplianceReport"),
MANAGE_GROW_CALENDAR: t("permManageGrowCalendar"),
}
const toggle = (perm: string) => {
if (selected.includes(perm)) {
onChange(selected.filter((p) => p !== perm))
} else {
onChange([...selected, perm])
}
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ALL_PERMISSIONS.map((perm) => (
<div key={perm} className="flex items-center space-x-2">
<Checkbox
id={`perm-${perm}`}
checked={selected.includes(perm)}
onCheckedChange={() => toggle(perm)}
/>
<Label
htmlFor={`perm-${perm}`}
className="cursor-pointer text-sm font-normal"
>
{permLabels[perm]}
</Label>
</div>
))}
</div>
)
}
// --- Invite Staff Sheet ---
function InviteStaffSheet({
open,
onOpenChange,
t,
}: {
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const inviteMutation = useInviteStaffMutation()
const [email, setEmail] = useState("")
const [displayName, setDisplayName] = useState("")
const [roleTemplate, setRoleTemplate] = useState("custom")
const [permissions, setPermissions] = useState<string[]>([])
const handleRoleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value
setRoleTemplate(value)
if (value !== "custom") {
setPermissions([...ROLE_TEMPLATES[value]])
}
}
const handleSubmit = () => {
const data: InviteStaffRequest = {
email,
displayName,
role: "STAFF",
permissions,
}
inviteMutation.mutate(data, {
onSuccess: () => {
toast({
description: t("inviteSuccess", { email }),
})
onOpenChange(false)
setEmail("")
setDisplayName("")
setRoleTemplate("custom")
setPermissions([])
},
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>{t("inviteTitle")}</SheetTitle>
<SheetDescription>{t("inviteDesc")}</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-5">
<div className="space-y-2">
<Label htmlFor="invite-name">{t("name")}</Label>
<Input
id="invite-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Max Mustermann"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-email">{t("inviteEmail")}</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">{t("inviteRole")}</Label>
<Select
id="invite-role"
value={roleTemplate}
onChange={handleRoleChange}
>
<option value="ausgabe">{t("roleAusgabe")}</option>
<option value="lager">{t("roleLager")}</option>
<option value="vorstand">{t("roleVorstand")}</option>
<option value="custom">{t("roleCustom")}</option>
</Select>
</div>
<div className="space-y-2">
<Label>{t("permissions")}</Label>
<PermissionCheckboxes
selected={permissions}
onChange={setPermissions}
t={t}
/>
</div>
<Button
className="w-full"
onClick={handleSubmit}
disabled={
!email ||
!displayName ||
permissions.length === 0 ||
inviteMutation.isPending
}
>
{t("inviteSend")}
</Button>
</div>
</SheetContent>
</Sheet>
)
}
// --- Edit Permissions Dialog ---
function EditPermissionsDialog({
staffMember,
open,
onOpenChange,
t,
}: {
staffMember: StaffMember | null
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const [permissions, setPermissions] = useState<string[]>(
staffMember?.permissions ?? []
)
const updateMutation = useUpdateStaffPermissionsMutation(
staffMember?.id ?? ""
)
// Sync permissions when staff member changes
const handleOpen = (isOpen: boolean) => {
if (isOpen && staffMember) {
setPermissions([...staffMember.permissions])
}
onOpenChange(isOpen)
}
const handleSave = () => {
updateMutation.mutate(
{ permissions },
{
onSuccess: () => {
toast({ description: t("permissionsSaved") })
onOpenChange(false)
},
}
)
}
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("editPermissions")}</DialogTitle>
<DialogDescription>
{staffMember?.displayName} {staffMember?.email}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PermissionCheckboxes
selected={permissions}
onChange={setPermissions}
t={t}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("cancel")}
</Button>
<Button
onClick={handleSave}
disabled={permissions.length === 0 || updateMutation.isPending}
>
{t("savePermissions")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Revoke Confirmation ---
function RevokeConfirmDialog({
staffMember,
open,
onOpenChange,
t,
}: {
staffMember: StaffMember | null
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const revokeMutation = useRevokeStaffMutation()
const handleRevoke = () => {
if (!staffMember) return
revokeMutation.mutate(staffMember.id, {
onSuccess: () => {
toast({ description: t("revokeSuccess") })
onOpenChange(false)
},
})
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("revokeAccess")}</AlertDialogTitle>
<AlertDialogDescription>
{t("revokeConfirm", { name: staffMember?.displayName ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("revokeAccess")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// --- Main Page ---
export default function StaffPage() {
const t = useTranslations("staff")
const { data: staffData, isLoading } = useStaffListQuery()
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<StaffMember | null>(null)
const [revokeTarget, setRevokeTarget] = useState<StaffMember | null>(null)
// Fallback to mock data when API unavailable
const staff: StaffMember[] = staffData ?? mockStaffAccounts
const permLabel = (perm: string): string => {
const labels: Record<string, string> = {
RECORD_DISTRIBUTION: t("permRecordDistribution"),
VIEW_MEMBER_LIST: t("permViewMemberList"),
VIEW_MEMBER_QUOTA: t("permViewMemberQuota"),
ADD_MEMBER: t("permAddMember"),
VIEW_STOCK: t("permViewStock"),
RECORD_STOCK_IN: t("permRecordStockIn"),
VIEW_COMPLIANCE_REPORT: t("permViewComplianceReport"),
MANAGE_GROW_CALENDAR: t("permManageGrowCalendar"),
}
return labels[perm] ?? perm
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<UserCog className="text-muted-foreground h-6 w-6" />
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
</div>
<Button onClick={() => setInviteOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("invite")}
</Button>
</div>
{/* Table */}
{isLoading ? (
<TableSkeleton />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("email")}</TableHead>
<TableHead>{t("role")}</TableHead>
<TableHead>{t("permissions")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.displayName}
</TableCell>
<TableCell className="text-muted-foreground">
{member.email}
</TableCell>
<TableCell>
<Badge variant="secondary">{member.role}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{member.permissions.slice(0, 3).map((perm) => (
<Badge key={perm} variant="outline" className="text-xs">
{permLabel(perm)}
</Badge>
))}
{member.permissions.length > 3 && (
<Badge variant="outline" className="text-xs">
+{member.permissions.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<StatusBadge status={member.status} t={t} />
</TableCell>
<TableCell className="text-right">
{member.status === "ACTIVE" && (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditTarget(member)}
>
<Edit className="mr-1 h-3.5 w-3.5" />
{t("editPermissions")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRevokeTarget(member)}
>
<ShieldX className="mr-1 h-3.5 w-3.5" />
{t("revokeAccess")}
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
{staff.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="text-muted-foreground py-8 text-center"
>
{t("noStaff")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Dialogs */}
<InviteStaffSheet open={inviteOpen} onOpenChange={setInviteOpen} t={t} />
<EditPermissionsDialog
staffMember={editTarget}
open={!!editTarget}
onOpenChange={(open) => {
if (!open) setEditTarget(null)
}}
t={t}
/>
<RevokeConfirmDialog
staffMember={revokeTarget}
open={!!revokeTarget}
onOpenChange={(open) => {
if (!open) setRevokeTarget(null)
}}
t={t}
/>
</div>
)
}
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,55 @@
import type { StaffMember } from "@/services/staff"
export const mockStaffAccounts: StaffMember[] = [
{
id: "staff-1",
displayName: "Maria Schulz",
email: "maria@gruener-daumen.de",
role: "STAFF",
permissions: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
],
status: "ACTIVE",
lastLoginAt: "2026-06-10T14:30:00Z",
createdAt: "2025-11-15T09:00:00Z",
},
{
id: "staff-2",
displayName: "Thomas Klein",
email: "thomas@gruener-daumen.de",
role: "STAFF",
permissions: ["VIEW_STOCK", "RECORD_STOCK_IN", "MANAGE_GROW_CALENDAR"],
status: "ACTIVE",
lastLoginAt: "2026-06-11T08:45:00Z",
createdAt: "2025-12-01T10:00:00Z",
},
{
id: "staff-3",
displayName: "Petra Wagner",
email: "petra@gruener-daumen.de",
role: "MANAGER",
permissions: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
],
status: "ACTIVE",
lastLoginAt: "2026-06-12T07:15:00Z",
createdAt: "2026-01-10T11:00:00Z",
},
{
id: "staff-4",
displayName: "Stefan Braun",
email: "stefan@gruener-daumen.de",
role: "STAFF",
permissions: ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST"],
status: "REVOKED",
createdAt: "2025-10-01T08:00:00Z",
},
]
+3 -3
View File
@@ -30,9 +30,9 @@ export const navigationsData: NavigationType[] = [
iconName: "FileText",
},
{
title: "Einstellungen",
href: "/settings",
iconName: "Settings",
title: "Personal",
href: "/settings/staff",
iconName: "UserCog",
},
],
},