Teamverwaltung für Apps
Team-Screens für Apps. Mitglieder mit Rollen und sicherem Entfernen, Einladungen mit geprüften Adressen und Link, Rollen mit Rechten, Teams als Baum und Plätze.
Menschen, Rollen und Plätze in Ordnung
Die Teamverwaltung ist die Seite, auf der eine Admin jemanden hereinholt, eine Rolle ändert oder einen Platz freigibt. Diese fünf Screens tun das mit Sorgfalt, Entfernen fragt erst nach und lässt sich zurücknehmen, jede E-Mail-Adresse wird geprüft, bevor eine Einladung rausgeht.
„Mitglieder“ zeigt eine Tabelle mit Suche, Rollenfilter und Status, eine Rolle je Person und Sammeländerungen, Telefone bekommen Karten. „Personen einladen“ nimmt E-Mail-Adressen als geprüfte Chips, die Rolle als Karten, listet offene Einladungen zum erneuten Senden oder Zurückziehen und bietet einen Beitrittslink zum Kopieren. „Rollen und Rechte“ führt Rollen mit ihren Personen und Rechte nach Gruppen mit Zählern, zum Bearbeiten, Duplizieren oder Ergänzen, die Inhaberrolle bleibt gesperrt. „Teams“ zeigt Abteilungen als Baum, durch den die Pfeiltasten wandern, mit Leitung und Köpfen je Team. „Plätze“ zählt belegte, durch Einladungen gehaltene und freie Plätze nach Rolle, nennt länger Abwesende und ergänzt Plätze mit dem heutigen Betrag.
Benenne Rollen nach dem, was Menschen tun, etwa Redaktion oder Abrechnung, nicht nach Stufen. Zeig die Kosten eines neuen Platzes vor dem Knopf, nicht danach. Lass Admins ein Entfernen einige Sekunden lang zurücknehmen, in langen Listen passieren Fehler.
Variants
Details
Was Teamverwaltung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Team und Zusammenarbeit
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/team-management.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 140 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- App-DashboardSaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TeamManagementProps aus components/ui/team-management.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalTeamManagementVariantmembersinviterolesteamsseats
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringmeoptionalstringYour member id
nowoptionalstringISO date and time that counts as now
membersoptionalTeamMember[]rolesoptionalTeamRole[]permissionsoptionalTeamPermission[]invitesoptionalTeamInvite[]teamsoptionalTeamGroup[]seatsoptionalTeamSeatsinviteLinkoptionalstringinvite: the join link
inviteDaysoptionalnumberinvite: days until an invitation runs out
inactiveDaysoptionalnumberseats: members away this many days count as inactive
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on every change. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Teamverwaltung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 140 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| emailHint | Englisch name@company.com | Deutsch name@firma.de |
| permYes | Englisch Allowed, | Deutsch Erlaubt, |
| permNo | Englisch Not part of this role, | Deutsch Nicht Teil dieser Rolle, |
| invitePeople | Englisch Invite people | Deutsch Personen einladen |
| search | Englisch Search by name or email | Deutsch Nach Name oder E-Mail suchen |
| allRoles | Englisch All roles | Deutsch Alle Rollen |
| role | Englisch Role | Deutsch Rolle |
| status | Englisch Status | Deutsch Status |
| all | Englisch All | Deutsch Alle |
| active | Englisch Active | Deutsch Aktiv |
| invited | Englisch Invited | Deutsch Eingeladen |
| suspended | Englisch Paused | Deutsch Pausiert |
| member | Englisch Member | Deutsch Mitglied |
| lastActive | Englisch Last active | Deutsch Zuletzt aktiv |
| never | Englisch Not yet | Deutsch Noch nicht |
| twoFactor | Englisch Two step sign in | Deutsch Anmeldung in zwei Schritten |
| twoOn | Englisch Two step sign in is on | Deutsch Anmeldung in zwei Schritten ist an |
| twoOff | Englisch Two step sign in is off | Deutsch Anmeldung in zwei Schritten ist aus |
| you | Englisch you | Deutsch du |
| count | Englisch {n} of {total} people | Deutsch {n} von {total} Personen |
| selected | Englisch {n} selected | Deutsch {n} ausgewählt |
| selectAll | Englisch Select every person shown | Deutsch Alle angezeigten Personen auswählen |
| select | Englisch Select {name} | Deutsch {name} auswählen |
| changeRole | Englisch Change role to | Deutsch Rolle ändern auf |
| remove | Englisch Remove | Deutsch Entfernen |
| removeName | Englisch Remove {name} | Deutsch {name} entfernen |
| removeTitle | Englisch Remove {name} from the team? | Deutsch {name} aus dem Team entfernen? |
| removeMany | Englisch Remove {n} people from the team? | Deutsch {n} Personen aus dem Team entfernen? |
| removeText | Englisch They lose access right away. Their work stays in the workspace and you can invite them again any time. | Deutsch Der Zugang endet sofort. Ihre Arbeit bleibt im Workspace, und du kannst sie jederzeit wieder einladen. |
| removeConfirm | Englisch Remove from team | Deutsch Aus dem Team entfernen |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| removed | Englisch {name} was removed. | Deutsch {name} wurde entfernt. |
| removedMany | Englisch {n} people were removed. | Deutsch {n} Personen wurden entfernt. |
| undo | Englisch Undo | Deutsch Rückgängig |
| restored | Englisch Back in the team. | Deutsch Wieder im Team. |
| roleChanged | Englisch {name} is now {role}. | Deutsch {name} ist jetzt {role}. |
| locked | Englisch The owner keeps every right. | Deutsch Der Inhaber behält alle Rechte. |
| noMatch | Englisch Nobody matches. Try another name or role. | Deutsch Niemand passt. Versuche einen anderen Namen oder eine andere Rolle. |
| emails | Englisch Email addresses | Deutsch E-Mail-Adressen |
| emailsHint | Englisch Separate them with a comma, a space or Enter. Pasting a list works too. | Deutsch Mit Komma, Leerzeichen oder Enter trennen. Eine eingefügte Liste klappt auch. |
| invalid | Englisch {n} addresses need a look | Deutsch {n} Adressen brauchen einen Blick |
| invalidOne | Englisch 1 address needs a look | Deutsch 1 Adresse braucht einen Blick |
| bad | Englisch Not an email address | Deutsch Keine E-Mail-Adresse |
| dup | Englisch Already added | Deutsch Schon hinzugefügt |
| exists | Englisch Already in the team | Deutsch Schon im Team |
| pendingAlready | Englisch Already invited | Deutsch Schon eingeladen |
| message | Englisch Personal note | Deutsch Persönliche Notiz |
| messageHint | Englisch Shown in the invitation email | Deutsch Steht in der Einladungsmail |
| optional | Englisch optional | Deutsch optional |
| send | Englisch Send invitations | Deutsch Einladungen senden |
| sendN | Englisch Send {n} invitations | Deutsch {n} Einladungen senden |
| sendOne | Englisch Send 1 invitation | Deutsch 1 Einladung senden |
| sending | Englisch Sending… | Deutsch Wird gesendet… |
| sentN | Englisch {n} invitations are on their way. | Deutsch {n} Einladungen sind unterwegs. |
| sentOne | Englisch The invitation is on its way. | Deutsch Die Einladung ist unterwegs. |
| pending | Englisch Pending invitations | Deutsch Offene Einladungen |
| noPending | Englisch Every invitation was answered. | Deutsch Alle Einladungen sind beantwortet. |
| sentBy | Englisch Sent {when} by {name} | Deutsch Gesendet {when} von {name} |
| sentAgo | Englisch Sent {when} | Deutsch Gesendet {when} |
| expires | Englisch Runs out {when} | Deutsch Läuft ab {when} |
| expired | Englisch Ran out | Deutsch Abgelaufen |
| resend | Englisch Send again | Deutsch Erneut senden |
| resending | Englisch Sending… | Deutsch Wird gesendet… |
| resent | Englisch Sent again | Deutsch Erneut gesendet |
| resentNote | Englisch Invitation to {email} sent again. | Deutsch Einladung an {email} erneut gesendet. |
| revoke | Englisch Withdraw | Deutsch Zurückziehen |
| revokeAsk | Englisch Withdraw it? | Deutsch Zurückziehen? |
| revokeYes | Englisch Yes, withdraw | Deutsch Ja, zurückziehen |
| revoked | Englisch Invitation to {email} withdrawn. | Deutsch Einladung an {email} zurückgezogen. |
| link | Englisch Invite link | Deutsch Einladungslink |
| linkText | Englisch Anyone with the link joins with the role you pick. | Deutsch Wer den Link hat, tritt mit der gewählten Rolle bei. |
| linkOn | Englisch Link is on | Deutsch Link ist an |
| linkOff | Englisch The link is off. Nobody can join with it. | Deutsch Der Link ist aus. Niemand kann damit beitreten. |
| copy | Englisch Copy link | Deutsch Link kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| copiedNote | Englisch Link copied. | Deutsch Link kopiert. |
| newLink | Englisch Make a new link | Deutsch Neuen Link erstellen |
| newLinkNote | Englisch New link made. The old one no longer works. | Deutsch Neuer Link erstellt. Der alte gilt nicht mehr. |
| linkRole | Englisch Joins as | Deutsch Tritt bei als |
| roles | Englisch Roles | Deutsch Rollen |
| newRole | Englisch New role | Deutsch Neue Rolle |
| newRoleName | Englisch New role | Deutsch Neue Rolle |
| peopleN | Englisch {n} people | Deutsch {n} Personen |
| personOne | Englisch 1 person | Deutsch 1 Person |
| edit | Englisch Edit role | Deutsch Rolle bearbeiten |
| duplicate | Englisch Duplicate | Deutsch Duplizieren |
| copyOf | Englisch {name} copy | Deutsch {name} Kopie |
| save | Englisch Save role | Deutsch Rolle speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| saved | Englisch Role saved. | Deutsch Rolle gespeichert. |
| discard | Englisch Discard changes | Deutsch Änderungen verwerfen |
| name | Englisch Name | Deutsch Name |
| description | Englisch Description | Deutsch Beschreibung |
| rights | Englisch Rights | Deutsch Rechte |
| ofN | Englisch {n} of {total} | Deutsch {n} von {total} |
| allIn | Englisch Every right in {group} | Deutsch Alle Rechte in {group} |
| withRole | Englisch People with this role | Deutsch Personen mit dieser Rolle |
| noneWithRole | Englisch Nobody has this role yet. | Deutsch Noch hat niemand diese Rolle. |
| teams | Englisch Teams | Deutsch Teams |
| expandAll | Englisch Expand all | Deutsch Alle aufklappen |
| lead | Englisch Lead | Deutsch Leitung |
| noLead | Englisch No lead yet | Deutsch Noch keine Leitung |
| changeLead | Englisch Change lead | Deutsch Leitung ändern |
| members | Englisch Members | Deutsch Mitglieder |
| addMember | Englisch Add to team | Deutsch Zum Team hinzufügen |
| pickPerson | Englisch Pick a person | Deutsch Person wählen |
| added | Englisch {name} joined {team}. | Deutsch {name} ist jetzt in {team}. |
| removedFrom | Englisch {name} left {team}. | Deutsch {name} hat {team} verlassen. |
| leadSet | Englisch {name} now leads {team}. | Deutsch {name} leitet jetzt {team}. |
| subTeams | Englisch Teams inside | Deutsch Teams darin |
| back | Englisch Back | Deutsch Zurück |
| seats | Englisch Seats | Deutsch Plätze |
| seatsUsed | Englisch {used} of {total} seats in use | Deutsch {used} von {total} Plätzen belegt |
| seatsFree | Englisch {n} free | Deutsch {n} frei |
| seatsFull | Englisch Every seat is taken | Deutsch Alle Plätze sind belegt |
| seatsPending | Englisch {n} held by invitations | Deutsch {n} durch Einladungen belegt |
| activeSeats | Englisch Active members | Deutsch Aktive Mitglieder |
| invitedSeats | Englisch Open invitations | Deutsch Offene Einladungen |
| freeSeats | Englisch Free | Deutsch Frei |
| byRole | Englisch Seats by role | Deutsch Plätze nach Rolle |
| addSeats | Englisch Add seats | Deutsch Plätze hinzufügen |
| seatsToAdd | Englisch Seats to add | Deutsch Neue Plätze |
| fewer | Englisch One seat less | Deutsch Ein Platz weniger |
| more | Englisch One seat more | Deutsch Ein Platz mehr |
| perSeat | Englisch {price} per seat and {interval} | Deutsch {price} je Platz und {interval} |
| month | Englisch month | Deutsch Monat |
| year | Englisch year | Deutsch Jahr |
| newTotal | Englisch New total {n} seats, {price} per {interval} | Deutsch Neu {n} Plätze, {price} je {interval} |
| today | Englisch Due today, for the days until {date} | Deutsch Heute fällig, für die Tage bis {date} |
| buy | Englisch Add {n} seats | Deutsch {n} Plätze hinzufügen |
| buyOne | Englisch Add 1 seat | Deutsch 1 Platz hinzufügen |
| buying | Englisch Adding… | Deutsch Wird hinzugefügt… |
| bought | Englisch {n} seats added. They are ready to use. | Deutsch {n} Plätze hinzugefügt. Sie sind sofort nutzbar. |
| inactive | Englisch Seats you can free | Deutsch Plätze, die du freigeben kannst |
| inactiveText | Englisch These people have not signed in for a while. | Deutsch Diese Personen haben sich länger nicht angemeldet. |
| awayFor | Englisch Away for {n} days | Deutsch Seit {n} Tagen weg |
| free | Englisch Free up seat | Deutsch Platz freigeben |
| freed | Englisch {name} is paused and the seat is free. | Deutsch {name} ist pausiert, der Platz ist frei. |
| noInactive | Englisch Everyone signed in lately. Every seat is in use. | Deutsch Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt. |
| renews | Englisch Renews {date} | Deutsch Verlängert sich am {date} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Teamverwaltung reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab, Rücktaste und Leertaste.
- Setzt aria-checked, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-level, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alertdialog, group, listbox, option, radio, radiogroup, status, tree und treeitem.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.900 Zeilen
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { CheckboxBox } from '@/components/ui/checkbox';
import { Dialog } from '@/components/ui/dialog';
import { Icon } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type TeamManagementVariant = 'members' | 'invite' | 'roles' | 'teams' | 'seats';
export interface TeamMember {
id: string;
name: string;
email: string;
/** Role id */
role: string;
status?: 'active' | 'invited' | 'suspended';
/** ISO date and time */
lastActive?: string;
twoFactor?: boolean;
}
export interface TeamRole {
id: string;
name: string;
description?: string;
/** Permission ids */
permissions: string[];
/** Owner like roles that cannot be changed or given away here */
locked?: boolean;
}
export interface TeamPermission {
id: string;
label: string;
/** Heading the permission is listed under */
group: string;
description?: string;
}
export interface TeamInvite {
id: string;
email: string;
/** Role id */
role: string;
/** ISO date and time */
sentAt: string;
/** Member id of who sent it */
by?: string;
}
export interface TeamGroup {
id: string;
name: string;
description?: string;
/** Member id */
lead?: string;
/** Member ids */
members: string[];
/** Id of the team above */
parent?: string;
}
export interface TeamSeats {
total: number;
/** Price of one seat per interval */
price: number;
currency?: string;
interval?: 'month' | 'year';
/** ISO date of the next renewal */
renews: string;
}
export interface TeamManagementProps {
variant?: TeamManagementVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** Your member id */
me?: string;
/** ISO date and time that counts as now */
now?: string;
members?: TeamMember[];
roles?: TeamRole[];
permissions?: TeamPermission[];
invites?: TeamInvite[];
teams?: TeamGroup[];
seats?: TeamSeats;
/** invite: the join link */
inviteLink?: string;
/** invite: days until an invitation runs out */
inviteDays?: number;
/** seats: members away this many days count as inactive */
inactiveDays?: number;
/** Called on every change. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
emailHint: 'name@company.com',
permYes: 'Allowed,',
permNo: 'Not part of this role,',
invitePeople: 'Invite people',
search: 'Search by name or email',
allRoles: 'All roles',
role: 'Role',
status: 'Status',
all: 'All',
active: 'Active',
invited: 'Invited',
suspended: 'Paused',
member: 'Member',
lastActive: 'Last active',
never: 'Not yet',
twoFactor: 'Two step sign in',
twoOn: 'Two step sign in is on',
twoOff: 'Two step sign in is off',
you: 'you',
count: '{n} of {total} people',
selected: '{n} selected',
selectAll: 'Select every person shown',
select: 'Select {name}',
changeRole: 'Change role to',
remove: 'Remove',
removeName: 'Remove {name}',
removeTitle: 'Remove {name} from the team?',
removeMany: 'Remove {n} people from the team?',
removeText: 'They lose access right away. Their work stays in the workspace and you can invite them again any time.',
removeConfirm: 'Remove from team',
cancel: 'Cancel',
removed: '{name} was removed.',
removedMany: '{n} people were removed.',
undo: 'Undo',
restored: 'Back in the team.',
roleChanged: '{name} is now {role}.',
locked: 'The owner keeps every right.',
noMatch: 'Nobody matches. Try another name or role.',
emails: 'Email addresses',
emailsHint: 'Separate them with a comma, a space or Enter. Pasting a list works too.',
invalid: '{n} addresses need a look',
invalidOne: '1 address needs a look',
bad: 'Not an email address',
dup: 'Already added',
exists: 'Already in the team',
pendingAlready: 'Already invited',
message: 'Personal note',
messageHint: 'Shown in the invitation email',
optional: 'optional',
send: 'Send invitations',
sendN: 'Send {n} invitations',
sendOne: 'Send 1 invitation',
sending: 'Sending…',
sentN: '{n} invitations are on their way.',
sentOne: 'The invitation is on its way.',
pending: 'Pending invitations',
noPending: 'Every invitation was answered.',
sentBy: 'Sent {when} by {name}',
sentAgo: 'Sent {when}',
expires: 'Runs out {when}',
expired: 'Ran out',
resend: 'Send again',
resending: 'Sending…',
resent: 'Sent again',
resentNote: 'Invitation to {email} sent again.',
revoke: 'Withdraw',
revokeAsk: 'Withdraw it?',
revokeYes: 'Yes, withdraw',
revoked: 'Invitation to {email} withdrawn.',
link: 'Invite link',
linkText: 'Anyone with the link joins with the role you pick.',
linkOn: 'Link is on',
linkOff: 'The link is off. Nobody can join with it.',
copy: 'Copy link',
copied: 'Copied',
copiedNote: 'Link copied.',
newLink: 'Make a new link',
newLinkNote: 'New link made. The old one no longer works.',
linkRole: 'Joins as',
roles: 'Roles',
newRole: 'New role',
newRoleName: 'New role',
peopleN: '{n} people',
personOne: '1 person',
edit: 'Edit role',
duplicate: 'Duplicate',
copyOf: '{name} copy',
save: 'Save role',
saving: 'Saving…',
saved: 'Role saved.',
discard: 'Discard changes',
name: 'Name',
description: 'Description',
rights: 'Rights',
ofN: '{n} of {total}',
allIn: 'Every right in {group}',
withRole: 'People with this role',
noneWithRole: 'Nobody has this role yet.',
teams: 'Teams',
expandAll: 'Expand all',
lead: 'Lead',
noLead: 'No lead yet',
changeLead: 'Change lead',
members: 'Members',
addMember: 'Add to team',
pickPerson: 'Pick a person',
added: '{name} joined {team}.',
removedFrom: '{name} left {team}.',
leadSet: '{name} now leads {team}.',
subTeams: 'Teams inside',
back: 'Back',
seats: 'Seats',
seatsUsed: '{used} of {total} seats in use',
seatsFree: '{n} free',
seatsFull: 'Every seat is taken',
seatsPending: '{n} held by invitations',
activeSeats: 'Active members',
invitedSeats: 'Open invitations',
freeSeats: 'Free',
byRole: 'Seats by role',
addSeats: 'Add seats',
seatsToAdd: 'Seats to add',
fewer: 'One seat less',
more: 'One seat more',
perSeat: '{price} per seat and {interval}',
month: 'month',
year: 'year',
newTotal: 'New total {n} seats, {price} per {interval}',
today: 'Due today, for the days until {date}',
buy: 'Add {n} seats',
buyOne: 'Add 1 seat',
buying: 'Adding…',
bought: '{n} seats added. They are ready to use.',
inactive: 'Seats you can free',
inactiveText: 'These people have not signed in for a while.',
awayFor: 'Away for {n} days',
free: 'Free up seat',
freed: '{name} is paused and the seat is free.',
noInactive: 'Everyone signed in lately. Every seat is in use.',
renews: 'Renews {date}',
},
de: {
emailHint: 'name@firma.de',
permYes: 'Erlaubt,',
permNo: 'Nicht Teil dieser Rolle,',
invitePeople: 'Personen einladen',
search: 'Nach Name oder E-Mail suchen',
allRoles: 'Alle Rollen',
role: 'Rolle',
status: 'Status',
all: 'Alle',
active: 'Aktiv',
invited: 'Eingeladen',
suspended: 'Pausiert',
member: 'Mitglied',
lastActive: 'Zuletzt aktiv',
never: 'Noch nicht',
twoFactor: 'Anmeldung in zwei Schritten',
twoOn: 'Anmeldung in zwei Schritten ist an',
twoOff: 'Anmeldung in zwei Schritten ist aus',
you: 'du',
count: '{n} von {total} Personen',
selected: '{n} ausgewählt',
selectAll: 'Alle angezeigten Personen auswählen',
select: '{name} auswählen',
changeRole: 'Rolle ändern auf',
remove: 'Entfernen',
removeName: '{name} entfernen',
removeTitle: '{name} aus dem Team entfernen?',
removeMany: '{n} Personen aus dem Team entfernen?',
removeText: 'Der Zugang endet sofort. Ihre Arbeit bleibt im Workspace, und du kannst sie jederzeit wieder einladen.',
removeConfirm: 'Aus dem Team entfernen',
cancel: 'Abbrechen',
removed: '{name} wurde entfernt.',
removedMany: '{n} Personen wurden entfernt.',
undo: 'Rückgängig',
restored: 'Wieder im Team.',
roleChanged: '{name} ist jetzt {role}.',
locked: 'Der Inhaber behält alle Rechte.',
noMatch: 'Niemand passt. Versuche einen anderen Namen oder eine andere Rolle.',
emails: 'E-Mail-Adressen',
emailsHint: 'Mit Komma, Leerzeichen oder Enter trennen. Eine eingefügte Liste klappt auch.',
invalid: '{n} Adressen brauchen einen Blick',
invalidOne: '1 Adresse braucht einen Blick',
bad: 'Keine E-Mail-Adresse',
dup: 'Schon hinzugefügt',
exists: 'Schon im Team',
pendingAlready: 'Schon eingeladen',
message: 'Persönliche Notiz',
messageHint: 'Steht in der Einladungsmail',
optional: 'optional',
send: 'Einladungen senden',
sendN: '{n} Einladungen senden',
sendOne: '1 Einladung senden',
sending: 'Wird gesendet…',
sentN: '{n} Einladungen sind unterwegs.',
sentOne: 'Die Einladung ist unterwegs.',
pending: 'Offene Einladungen',
noPending: 'Alle Einladungen sind beantwortet.',
sentBy: 'Gesendet {when} von {name}',
sentAgo: 'Gesendet {when}',
expires: 'Läuft ab {when}',
expired: 'Abgelaufen',
resend: 'Erneut senden',
resending: 'Wird gesendet…',
resent: 'Erneut gesendet',
resentNote: 'Einladung an {email} erneut gesendet.',
revoke: 'Zurückziehen',
revokeAsk: 'Zurückziehen?',
revokeYes: 'Ja, zurückziehen',
revoked: 'Einladung an {email} zurückgezogen.',
link: 'Einladungslink',
linkText: 'Wer den Link hat, tritt mit der gewählten Rolle bei.',
linkOn: 'Link ist an',
linkOff: 'Der Link ist aus. Niemand kann damit beitreten.',
copy: 'Link kopieren',
copied: 'Kopiert',
copiedNote: 'Link kopiert.',
newLink: 'Neuen Link erstellen',
newLinkNote: 'Neuer Link erstellt. Der alte gilt nicht mehr.',
linkRole: 'Tritt bei als',
roles: 'Rollen',
newRole: 'Neue Rolle',
newRoleName: 'Neue Rolle',
peopleN: '{n} Personen',
personOne: '1 Person',
edit: 'Rolle bearbeiten',
duplicate: 'Duplizieren',
copyOf: '{name} Kopie',
save: 'Rolle speichern',
saving: 'Wird gespeichert…',
saved: 'Rolle gespeichert.',
discard: 'Änderungen verwerfen',
name: 'Name',
description: 'Beschreibung',
rights: 'Rechte',
ofN: '{n} von {total}',
allIn: 'Alle Rechte in {group}',
withRole: 'Personen mit dieser Rolle',
noneWithRole: 'Noch hat niemand diese Rolle.',
teams: 'Teams',
expandAll: 'Alle aufklappen',
lead: 'Leitung',
noLead: 'Noch keine Leitung',
changeLead: 'Leitung ändern',
members: 'Mitglieder',
addMember: 'Zum Team hinzufügen',
pickPerson: 'Person wählen',
added: '{name} ist jetzt in {team}.',
removedFrom: '{name} hat {team} verlassen.',
leadSet: '{name} leitet jetzt {team}.',
subTeams: 'Teams darin',
back: 'Zurück',
seats: 'Plätze',
seatsUsed: '{used} von {total} Plätzen belegt',
seatsFree: '{n} frei',
seatsFull: 'Alle Plätze sind belegt',
seatsPending: '{n} durch Einladungen belegt',
activeSeats: 'Aktive Mitglieder',
invitedSeats: 'Offene Einladungen',
freeSeats: 'Frei',
byRole: 'Plätze nach Rolle',
addSeats: 'Plätze hinzufügen',
seatsToAdd: 'Neue Plätze',
fewer: 'Ein Platz weniger',
more: 'Ein Platz mehr',
perSeat: '{price} je Platz und {interval}',
month: 'Monat',
year: 'Jahr',
newTotal: 'Neu {n} Plätze, {price} je {interval}',
today: 'Heute fällig, für die Tage bis {date}',
buy: '{n} Plätze hinzufügen',
buyOne: '1 Platz hinzufügen',
buying: 'Wird hinzugefügt…',
bought: '{n} Plätze hinzugefügt. Sie sind sofort nutzbar.',
inactive: 'Plätze, die du freigeben kannst',
inactiveText: 'Diese Personen haben sich länger nicht angemeldet.',
awayFor: 'Seit {n} Tagen weg',
free: 'Platz freigeben',
freed: '{name} ist pausiert, der Platz ist frei.',
noInactive: 'Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt.',
renews: 'Verlängert sich am {date}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* b5t-rise entrance of cards and rows, staggered with --b5t-d
* b5t-in something that appears after an action
* b5t-pop chips and marks
* b5t-out a row on its way out
* b5t-fill bars grow from the start
*/
const CSS =
'@keyframes b5t-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5t-pop{from{opacity:0;transform:scale(.7)}}@keyframes b5t-fill{from{transform:scaleX(0)}}@keyframes b5t-shake{25%,75%{transform:translateX(-4px)}50%{transform:translateX(4px)}}' +
'.b5t-fill{transform-origin:left}' +
'@media (prefers-reduced-motion:no-preference){.b5t-rise{animation:b5t-rise .6s var(--ease-kit) var(--b5t-d,0ms) both}.b5t-in{animation:b5t-rise .4s var(--ease-kit) both}.b5t-pop{animation:b5t-pop .35s cubic-bezier(.34,1.56,.64,1) both}.b5t-fill{animation:b5t-fill 1s var(--ease-kit) var(--b5t-d,150ms) both}.b5t-shake{animation:b5t-shake .35s ease-in-out}}';
function Style() {
return (
<style href="kitb5-team-management" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : ms));
async function run(onAction: TeamManagementProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(ms);
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
if (a < 1440 * 45) return rtf.format(Math.round(m / 1440), 'day');
return rtf.format(Math.round(m / (1440 * 30)), 'month');
}
const days = (from: string, to: string) => Math.round((toMs(to) - toMs(from)) / (1440 * MIN));
const dateFmt = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(toMs(iso)));
const money = (n: number, l: Locale, currency = 'EUR') => new Intl.NumberFormat(intl(l), { style: 'currency', currency, maximumFractionDigits: n % 1 ? 2 : 0, minimumFractionDigits: n % 1 ? 2 : 0 }).format(n);
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const accentText = 'text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]';
type Ctx = Omit<TeamManagementProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; now: string };
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: string; children?: React.ReactNode }) {
return (
<div className="b5t-rise flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName, delay = 0, headingLevel = 2 }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; delay?: number; headingLevel?: 2 | 3 }) {
const H = headingLevel === 2 ? 'h2' : 'h3';
return (
<section className={cn('b5t-rise flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)} style={{ '--b5t-d': `${delay}ms` } as React.CSSProperties}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <H className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</H>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Pick({ label, value, onChange, children, className, disabled, hideLabel = true }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; disabled?: boolean; hideLabel?: boolean }) {
return (
<label className={cn('relative block min-w-0', className)}>
<span className={hideLabel ? 'sr-only' : 'mb-1.5 block text-[12.5px] font-medium text-muted-foreground'}>{label}</span>
<span className="relative block">
<select disabled={disabled} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8 hover:border-border-strong disabled:cursor-not-allowed disabled:bg-surface disabled:text-muted-foreground')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
);
}
function SearchBox({ label, value, onChange, className }: { label: string; value: string; onChange: (v: string) => void; className?: string }) {
return (
<label className={cn('relative block', className)}>
<span className="sr-only">{label}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className={cn(field, 'ps-9')} />
</label>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + options.length) % options.length;
onChange(options[n].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{o.label}
</button>
))}
</div>
);
}
const STATUS_TONE: Record<NonNullable<TeamMember['status']>, string> = {
active: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground',
invited: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] ' + accentText,
suspended: 'bg-muted text-muted-foreground',
};
function StatusBadge({ s, t }: { s: NonNullable<TeamMember['status']>; t: Texts }) {
return (
<span className={cn('inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium', STATUS_TONE[s])}>
<span aria-hidden className={cn('size-1.5 rounded-full', s === 'active' ? 'bg-success' : s === 'invited' ? 'bg-primary' : 'bg-subtle-foreground')} />
{t[s]}
</span>
);
}
function RoleBadge({ name, strong }: { name: string; strong?: boolean }) {
return <span className={cn('inline-flex h-6 items-center rounded-md border px-2 text-[12px] font-medium', strong ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] ' + accentText : 'border-border bg-surface text-muted-foreground')}>{name}</span>;
}
/** A note with an undo button, it stays a few seconds */
function UndoBar({ note, onUndo, onClose, t }: { note: string; onUndo?: () => void; onClose: () => void; t: Texts }) {
useEffect(() => {
const id = window.setTimeout(onClose, 7000);
return () => window.clearTimeout(id);
}, [note, onClose]);
return (
<div className="b5t-in fixed inset-x-3 bottom-3 z-40 mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 pe-2 ps-4 text-[13.5px] text-foreground shadow-[var(--highlight),var(--shadow-lg)] sm:bottom-6">
<Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1">{note}</span>
{onUndo && (
<Button size="sm" variant="ghost" onClick={onUndo}>
{t.undo}
</Button>
)}
<IconButton icon="x" size="sm" label={t.cancel} onClick={onClose} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* members */
/* ------------------------------------------------------------------ */
function Members(c: Ctx) {
const { t, locale, now, roles = [], me, onAction } = c;
const [list, setList] = useState(c.members ?? []);
const [q, setQ] = useState('');
const [role, setRole] = useState('all');
const [status, setStatus] = useState<'all' | 'active' | 'invited'>('all');
const [sel, setSel] = useState<string[]>([]);
const [confirm, setConfirm] = useState<string[] | null>(null);
const [undo, setUndo] = useState<{ note: string; restore?: TeamMember[] } | null>(null);
const [live, setLive] = useState('');
const roleOf = (id: string) => roles.find((r) => r.id === id);
const shown = list.filter((m) => (role === 'all' || m.role === role) && (status === 'all' || (m.status ?? 'active') === status) && (!q || `${m.name} ${m.email}`.toLowerCase().includes(q.toLowerCase())));
const locked = (m: TeamMember) => !!roleOf(m.role)?.locked || m.id === me;
const selectable = shown.filter((m) => !locked(m));
const allOn = selectable.length > 0 && selectable.every((m) => sel.includes(m.id));
const someOn = selectable.some((m) => sel.includes(m.id));
const setRoleFor = (ids: string[], r: string) => {
setList((l) => l.map((m) => (ids.includes(m.id) ? { ...m, role: r } : m)));
const name = ids.length === 1 ? list.find((m) => m.id === ids[0])?.name ?? '' : fill(t.selected, { n: ids.length });
setLive(fill(t.roleChanged, { name, role: roleOf(r)?.name ?? r }));
void run(onAction, 'role', { ids, role: r });
};
const doRemove = async (ids: string[]) => {
const gone = list.filter((m) => ids.includes(m.id));
setList((l) => l.filter((m) => !ids.includes(m.id)));
setSel((s) => s.filter((x) => !ids.includes(x)));
const note = gone.length === 1 ? fill(t.removed, { name: gone[0].name }) : fill(t.removedMany, { n: gone.length });
setUndo({ note, restore: gone });
setLive(note);
await run(onAction, 'remove', { ids });
};
const target = confirm?.length === 1 ? list.find((m) => m.id === confirm[0]) : undefined;
const roleOptions = roles.filter((r) => !r.locked);
const Last = ({ m }: { m: TeamMember }) => <span className="tabular-nums">{m.lastActive ? ago(m.lastActive, now, locale) : t.never}</span>;
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button size="sm" iconLeft="user-plus" magnetic onClick={() => void onAction?.('invite', {})}>
{t.invitePeople}
</Button>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
<div className="b5t-rise grid grid-cols-[minmax(0,1fr)] gap-3 [--b5t-d:60ms] lg:flex lg:items-center lg:justify-between">
<SearchBox label={t.search} value={q} onChange={setQ} className="lg:w-72" />
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:flex sm:items-center">
<Pick label={t.role} value={role} onChange={setRole} className="sm:w-44">
<option value="all">{t.allRoles}</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Segmented
label={t.status}
value={status}
onChange={setStatus}
className="flex w-full sm:w-auto"
options={[
{ value: 'all', label: t.all },
{ value: 'active', label: t.active },
{ value: 'invited', label: <>{t.invited}<span className="text-[12px] tabular-nums text-subtle-foreground">{list.filter((m) => m.status === 'invited').length}</span></> },
]}
/>
</div>
</div>
{/* Bulk bar: only while people are selected */}
<div className={cn('grid transition-[grid-template-rows,opacity,margin] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', sel.length ? 'grid-rows-[1fr] opacity-100' : 'invisible -mt-4 grid-rows-[0fr] opacity-0')} inert={!sel.length}>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] px-4 py-2.5">
<span className="text-[13.5px] font-semibold tabular-nums text-foreground">{fill(t.selected, { n: sel.length })}</span>
<span className="flex-1" />
<Pick label={t.changeRole} value="" onChange={(v) => v && setRoleFor(sel, v)} className="w-full sm:w-48">
<option value="">{t.changeRole}</option>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Button size="sm" variant="destructive" iconLeft="trash" className="max-sm:w-full" onClick={() => setConfirm(sel)}>
{t.remove}
</Button>
</div>
</div>
</div>
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.count, { n: shown.length, total: list.length })}
</p>
{/* Tablet and desktop: a table */}
<div className="b5t-rise overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5t-d:120ms] max-md:hidden">
<table className="w-full table-fixed text-left text-[13.5px]">
<thead className="border-b border-border bg-surface text-[12px] font-medium text-muted-foreground">
<tr>
<th scope="col" className="w-12 py-2.5 ps-4">
<label className="grid size-8 cursor-pointer place-items-center">
<span className="sr-only">{t.selectAll}</span>
<CheckboxBox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setSel(allOn ? [] : selectable.map((m) => m.id))} />
</label>
</th>
<th scope="col" className="py-2.5 ps-2 font-medium">{t.member}</th>
<th scope="col" className="w-44 py-2.5 font-medium xl:w-52">{t.role}</th>
<th scope="col" className="w-32 py-2.5 font-medium">{t.status}</th>
<th scope="col" className="w-32 py-2.5 font-medium max-xl:hidden">{t.lastActive}</th>
<th scope="col" className="w-14 py-2.5 pe-3">
<span className="sr-only">{t.remove}</span>
</th>
</tr>
</thead>
<tbody>
{shown.map((m, i) => {
const lock = locked(m);
const on = sel.includes(m.id);
return (
<tr key={m.id} className={cn('b5t-rise border-b border-border transition-colors last:border-b-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')} style={{ '--b5t-d': `${160 + i * 40}ms` } as React.CSSProperties}>
<td className="py-3 ps-4">
{!lock && (
<label className="grid size-8 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.select, { name: m.name })}</span>
<CheckboxBox checked={on} onChange={() => setSel((s) => (on ? s.filter((x) => x !== m.id) : [...s, m.id]))} />
</label>
)}
</td>
<td className="py-3 ps-2">
<div className="flex min-w-0 items-center gap-3">
<Avatar name={m.name} size="sm" alt="" />
<div className="min-w-0">
<p className="flex items-center gap-1.5 truncate font-medium text-foreground">
<span className="truncate">{m.name}</span>
{m.id === me && <span className="shrink-0 text-[12px] font-normal text-subtle-foreground">({t.you})</span>}
{m.twoFactor !== undefined && <Icon name={m.twoFactor ? 'shield' : 'unlock'} size={13} label={m.twoFactor ? t.twoOn : t.twoOff} className={cn('shrink-0', m.twoFactor ? 'text-success-foreground' : 'text-subtle-foreground')} />}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{m.email}</p>
</div>
</div>
</td>
<td className="py-3 pe-3">
{lock && roleOf(m.role)?.locked ? (
<span title={t.locked}>
<RoleBadge name={roleOf(m.role)?.name ?? m.role} strong />
</span>
) : (
<Pick label={fill(t.changeRole, {}) + ' ' + m.name} value={m.role} onChange={(v) => setRoleFor([m.id], v)} disabled={m.id === me}>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
)}
</td>
<td className="py-3">
<StatusBadge s={m.status ?? 'active'} t={t} />
</td>
<td className="py-3 text-[13px] text-muted-foreground max-xl:hidden">
<Last m={m} />
</td>
<td className="py-3 pe-3 text-right">{!lock && <IconButton icon="trash" size="sm" label={fill(t.removeName, { name: m.name })} onClick={() => setConfirm([m.id])} className="hover:text-destructive" />}</td>
</tr>
);
})}
</tbody>
</table>
{!shown.length && <p className="px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>}
</div>
{/* Phone: cards */}
<ul className="grid gap-3 md:hidden">
{shown.map((m, i) => {
const lock = locked(m);
const on = sel.includes(m.id);
return (
<li key={m.id} className={cn('b5t-rise rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]', on ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-border')} style={{ '--b5t-d': `${120 + i * 40}ms` } as React.CSSProperties}>
<div className="flex items-start gap-3">
<Avatar name={m.name} size="md" alt="" />
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-semibold text-foreground">
{m.name}
{m.id === me && <span className="ms-1 text-[12px] font-normal text-subtle-foreground">({t.you})</span>}
</p>
<p className="truncate text-[13px] text-muted-foreground">{m.email}</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-subtle-foreground">
<StatusBadge s={m.status ?? 'active'} t={t} />
{m.lastActive && (
<span>
{t.lastActive} <Last m={m} />
</span>
)}
</p>
</div>
{!lock && (
<label className="-me-2 -mt-2 grid size-11 shrink-0 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.select, { name: m.name })}</span>
<CheckboxBox checked={on} onChange={() => setSel((s) => (on ? s.filter((x) => x !== m.id) : [...s, m.id]))} />
</label>
)}
</div>
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
{lock && roleOf(m.role)?.locked ? (
<p className="flex-1 text-[12.5px] text-muted-foreground">
<RoleBadge name={roleOf(m.role)?.name ?? ''} strong /> <span className="ms-1">{t.locked}</span>
</p>
) : (
<Pick label={`${t.role} ${m.name}`} value={m.role} onChange={(v) => setRoleFor([m.id], v)} className="flex-1" disabled={m.id === me}>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
)}
{!lock && <IconButton icon="trash" size="md" variant="outline" label={fill(t.removeName, { name: m.name })} onClick={() => setConfirm([m.id])} />}
</div>
</li>
);
})}
{!shown.length && <li className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</li>}
</ul>
</div>
<Dialog
open={!!confirm}
onOpenChange={(o) => !o && setConfirm(null)}
role="alertdialog"
icon="user-minus"
tone="destructive"
size="sm"
title={target ? fill(t.removeTitle, { name: target.name }) : fill(t.removeMany, { n: confirm?.length ?? 0 })}
description={t.removeText}
footer={(close) => (
<>
<Button variant="outline" onClick={close}>
{t.cancel}
</Button>
<Button
variant="destructive"
onClick={() => {
const ids = confirm ?? [];
close();
void doRemove(ids);
}}
>
{t.removeConfirm}
</Button>
</>
)}
/>
{undo && (
<UndoBar
note={undo.note}
t={t}
onClose={() => setUndo(null)}
onUndo={
undo.restore
? () => {
const back = undo.restore ?? [];
setList((l) => [...l, ...back].sort((a, b) => (c.members ?? []).findIndex((x) => x.id === a.id) - (c.members ?? []).findIndex((x) => x.id === b.id)));
setUndo(null);
setLive(t.restored);
void run(onAction, 'restore', { ids: back.map((m) => m.id) });
}
: undefined
}
/>
)}
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* invite */
/* ------------------------------------------------------------------ */
const EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[a-z]{2,}$/i;
type Chip = { value: string; error?: 'bad' | 'dup' | 'exists' | 'pendingAlready' };
function EmailChips({ chips, setChips, t, id, known, pending, describedBy }: { chips: Chip[]; setChips: (fn: (c: Chip[]) => Chip[]) => void; t: Texts; id: string; known: string[]; pending: string[]; describedBy: string }) {
const [text, setText] = useState('');
const input = useRef<HTMLInputElement>(null);
const check = (v: string, list: Chip[]): Chip => {
const low = v.toLowerCase();
if (!EMAIL.test(v)) return { value: v, error: 'bad' };
if (list.some((c) => c.value.toLowerCase() === low)) return { value: v, error: 'dup' };
if (known.includes(low)) return { value: v, error: 'exists' };
if (pending.includes(low)) return { value: v, error: 'pendingAlready' };
return { value: v };
};
const add = (raw: string) => {
const parts = raw.split(/[\s,;]+/).map((s) => s.trim().replace(/^<|>$/g, '')).filter(Boolean);
if (!parts.length) return;
setChips((list) => parts.reduce<Chip[]>((acc, p) => [...acc, check(p, acc)], list));
setText('');
};
return (
<div onClick={() => input.current?.focus()} className="flex min-h-[5.5rem] cursor-text flex-wrap content-start items-center gap-1.5 rounded-lg border border-input bg-background p-2 shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]">
<ul className="contents" aria-label={t.emails}>
{chips.map((c, i) => (
<li key={c.value + i} className={cn('b5t-pop inline-flex h-8 max-w-full items-center gap-1 rounded-md border ps-2 pe-0.5 text-[13px]', c.error ? 'b5t-shake border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_8%,var(--card))] text-foreground' : 'border-border bg-surface text-foreground')}>
{c.error ? <Icon name="alert-circle" size={13} className="shrink-0 text-destructive" /> : <Avatar name={c.value} size="xs" alt="" className="size-5 text-[8px]" />}
<span className="truncate">{c.value}</span>
{c.error && <span className="shrink-0 text-[11.5px] text-destructive max-sm:sr-only">{t[c.error]}</span>}
{c.error && <span className="sr-only">{t[c.error]}</span>}
<IconButton icon="x" size="xs" label={fill(t.removeName, { name: c.value })} onClick={(e) => (e.stopPropagation(), setChips((l) => l.filter((_, k) => k !== i)), input.current?.focus())} className="size-7" />
</li>
))}
</ul>
<input
ref={input}
id={id}
type="email"
multiple
value={text}
aria-describedby={describedBy}
aria-invalid={chips.some((c) => c.error) || undefined}
onChange={(e) => {
const v = e.target.value;
if (/[\s,;]$/.test(v)) add(v);
else setText(v);
}}
onPaste={(e) => {
const v = e.clipboardData.getData('text');
if (/[\s,;]/.test(v.trim())) {
e.preventDefault();
add(text + v);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') {
if (text.trim()) {
e.preventDefault();
add(text);
}
} else if (e.key === 'Backspace' && !text && chips.length) setChips((l) => l.slice(0, -1));
}}
onBlur={() => text.trim() && add(text)}
placeholder={chips.length ? '' : t.emailHint}
className="h-11 min-w-[10rem] flex-1 bg-transparent px-1 text-base sm:h-8 text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13.5px]"
/>
</div>
);
}
function RoleCards({ roles, value, onChange, label }: { roles: TeamRole[]; value: string; onChange: (v: string) => void; label: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className="grid gap-2 sm:grid-cols-3">
{roles.map((r, i) => {
const on = r.id === value;
return (
<button
key={r.id}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(r.id)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + roles.length) % roles.length;
onChange(roles[n].id);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('relative rounded-lg border p-3 text-left transition-[border-color,background-color,box-shadow,translate] duration-200 hover:-translate-y-px', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border bg-card hover:border-border-strong', ring)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-semibold text-foreground">{r.name}</span>
<span className={cn('grid size-4 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-border-strong')}>{on && <span className="b5t-pop size-1.5 rounded-full bg-primary-foreground" />}</span>
</span>
{r.description && <span className="mt-1 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{r.description}</span>}
</button>
);
})}
</div>
);
}
function Invite(c: Ctx) {
const { t, locale, now, roles = [], members = [], onAction } = c;
const id = useId();
const pickRoles = roles.filter((r) => !r.locked);
const [chips, setChips] = useState<Chip[]>([]);
const [role, setRole] = useState(pickRoles.find((r) => r.id === 'member')?.id ?? pickRoles[0]?.id ?? '');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState(c.invites ?? []);
const [fresh, setFresh] = useState<string[]>([]);
const [resending, setResending] = useState('');
const [resent, setResent] = useState<string[]>([]);
const [asking, setAsking] = useState('');
const [linkOn, setLinkOn] = useState(true);
const [linkRole, setLinkRole] = useState(role);
const [link, setLink] = useState(c.inviteLink ?? 'https://app.northwind.io/join/7f3k-q9tm');
const [copied, setCopied] = useState(false);
const [live, setLive] = useState('');
const valid = chips.filter((x) => !x.error);
const bad = chips.length - valid.length;
const life = c.inviteDays ?? 7;
const name = (mid?: string) => members.find((m) => m.id === mid)?.name;
const send = async () => {
if (!valid.length) return;
setBusy(true);
await run(onAction, 'invite', { emails: valid.map((x) => x.value), role, note });
const added = valid.map((x, i) => ({ id: `i${Date.now()}${i}`, email: x.value, role, sentAt: now, by: c.me }));
setPending((p) => [...added, ...p]);
setFresh(added.map((a) => a.id));
setChips((l) => l.filter((x) => x.error));
setNote('');
setBusy(false);
setLive(added.length === 1 ? t.sentOne : fill(t.sentN, { n: added.length }));
};
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] xl:items-start">
<div className="grid gap-6">
<Card title={t.invitePeople} delay={60}>
<div className="grid gap-5">
<div className="grid gap-1.5">
<label htmlFor={`${id}-e`} className="text-[13px] font-medium text-foreground">
{t.emails}
</label>
<EmailChips chips={chips} setChips={setChips} t={t} id={`${id}-e`} known={members.map((m) => m.email.toLowerCase())} pending={pending.map((p) => p.email.toLowerCase())} describedBy={`${id}-eh`} />
<p id={`${id}-eh`} className={cn('text-[12.5px]', bad ? 'text-destructive' : 'text-muted-foreground')}>
{bad ? (bad === 1 ? t.invalidOne : fill(t.invalid, { n: bad })) : t.emailsHint}
</p>
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium text-foreground">{t.role}</span>
<RoleCards roles={pickRoles} value={role} onChange={setRole} label={t.role} />
</div>
<div className="grid gap-1.5">
<label htmlFor={`${id}-n`} className="text-[13px] font-medium text-foreground">
{t.message} <span className="font-normal text-subtle-foreground">({t.optional})</span>
</label>
<textarea id={`${id}-n`} rows={2} value={note} onChange={(e) => setNote(e.target.value)} aria-describedby={`${id}-nh`} className={cn(field, 'h-auto min-h-20 resize-y py-2 sm:h-auto')} />
<p id={`${id}-nh`} className="text-[12.5px] text-muted-foreground">
{t.messageHint}
</p>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button iconLeft={busy ? 'loader' : 'send'} disabled={!valid.length || busy} onClick={send} className="max-sm:w-full">
{busy ? t.sending : valid.length === 1 ? t.sendOne : valid.length ? fill(t.sendN, { n: valid.length }) : t.send}
</Button>
</div>
</div>
</Card>
<Card title={t.link} description={t.linkText} delay={140} action={<Switch checked={linkOn} onCheckedChange={(v) => (setLinkOn(v), void run(onAction, 'link', { on: v }))} aria-label={t.linkOn} />}>
{linkOn ? (
<div className="b5t-in grid gap-3">
<div className="flex flex-col gap-2 sm:flex-row">
<label className="min-w-0 flex-1">
<span className="sr-only">{t.link}</span>
<input readOnly value={link} onFocus={(e) => e.currentTarget.select()} className={cn(field, 'bg-surface text-muted-foreground')} />
</label>
<Button
variant={copied ? 'secondary' : 'outline'}
size="sm"
iconLeft={copied ? 'check' : 'copy'}
className="sm:h-9"
onClick={() => {
void navigator.clipboard?.writeText(link).catch(() => undefined);
setCopied(true);
setLive(t.copiedNote);
window.setTimeout(() => setCopied(false), 1800);
}}
>
{copied ? t.copied : t.copy}
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-3">
<Pick label={t.linkRole} hideLabel={false} value={linkRole} onChange={setLinkRole} className="w-full sm:w-52">
{pickRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Button
variant="ghost"
size="sm"
iconLeft="refresh"
onClick={() => {
const tok = Math.random().toString(36).slice(2, 6) + '-' + Math.random().toString(36).slice(2, 6);
setLink(link.replace(/[^/]+$/, tok));
setLive(t.newLinkNote);
void run(onAction, 'new-link', {});
}}
>
{t.newLink}
</Button>
</div>
</div>
) : (
<p className="b5t-in flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="lock" size={15} className="shrink-0" />
{t.linkOff}
</p>
)}
</Card>
</div>
<Card title={t.pending} delay={200} bodyClassName="p-0 sm:p-0" action={<span className="text-[12.5px] tabular-nums text-muted-foreground">{pending.length}</span>} className="xl:sticky xl:top-6">
{pending.length ? (
<ul>
{pending.map((p, i) => {
const left = life - days(p.sentAt, now);
const expired = left <= 0;
const r = roles.find((x) => x.id === p.role);
return (
<li key={p.id} className={cn('flex flex-col gap-3 border-b border-border px-4 py-3.5 last:border-b-0 sm:flex-row sm:items-center sm:px-5 xl:flex-col xl:items-stretch 2xl:flex-row 2xl:items-center', fresh.includes(p.id) ? 'b5t-in bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'b5t-rise')} style={{ '--b5t-d': `${240 + i * 40}ms` } as React.CSSProperties}>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={p.email} size="sm" alt="" tone="neutral" />
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{p.email}</span>
{r && <RoleBadge name={r.name} />}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{resent.includes(p.id) ? t.resent : name(p.by) ? fill(t.sentBy, { when: ago(p.sentAt, now, locale), name: name(p.by)! }) : fill(t.sentAgo, { when: ago(p.sentAt, now, locale) })}</p>
<p className={cn('flex items-center gap-1 text-[12.5px]', expired ? 'font-medium text-warning-foreground' : 'text-subtle-foreground')}>
<Icon name={expired ? 'alert-circle' : 'clock'} size={12} className="shrink-0" />
{expired ? t.expired : fill(t.expires, { when: ago(new Date(toMs(p.sentAt) + life * 1440 * MIN).toISOString().slice(0, 16), now, locale) })}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 max-sm:ps-11 xl:ps-11 2xl:ps-0">
{asking === p.id ? (
<>
<span className="text-[12.5px] font-medium text-foreground max-sm:hidden">{t.revokeAsk}</span>
<Button size="sm" variant="ghost" onClick={() => setAsking('')}>
{t.cancel}
</Button>
<Button
size="sm"
variant="destructive"
autoFocus
onClick={() => {
setPending((l) => l.filter((x) => x.id !== p.id));
setAsking('');
setLive(fill(t.revoked, { email: p.email }));
void run(onAction, 'revoke', { id: p.id });
}}
>
{t.revokeYes}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
iconLeft={resending === p.id ? 'loader' : resent.includes(p.id) ? 'check' : 'refresh'}
disabled={resending === p.id || resent.includes(p.id)}
onClick={async () => {
setResending(p.id);
await run(onAction, 'resend', { id: p.id });
setPending((l) => l.map((x) => (x.id === p.id ? { ...x, sentAt: now } : x)));
setResent((l) => [...l, p.id]);
setResending('');
setLive(fill(t.resentNote, { email: p.email }));
}}
>
{resending === p.id ? t.resending : resent.includes(p.id) ? t.resent : t.resend}
</Button>
<Button size="sm" variant="ghost" onClick={() => setAsking(p.id)}>
{t.revoke}
</Button>
</>
)}
</div>
</li>
);
})}
</ul>
) : (
<p className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.noPending}</p>
)}
</Card>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* roles */
/* ------------------------------------------------------------------ */
function Roles(c: Ctx) {
const { t, permissions = [], members = [], onAction } = c;
const [roles, setRoles] = useState(c.roles ?? []);
const [current, setCurrent] = useState((c.roles ?? []).find((r) => !r.locked)?.id ?? c.roles?.[0]?.id ?? '');
const [draft, setDraft] = useState<TeamRole | null>(null);
const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
const [view, setView] = useState<'list' | 'detail'>('detail');
const [live, setLive] = useState('');
const listRef = useRef<HTMLUListElement>(null);
const id = useId();
const role = roles.find((r) => r.id === current) ?? roles[0];
const shown = draft ?? role;
const groups = useMemo(() => [...new Set(permissions.map((p) => p.group))], [permissions]);
const people = (rid: string) => members.filter((m) => m.role === rid);
const choose = (rid: string) => {
setCurrent(rid);
setDraft(null);
setState('idle');
setView('detail');
};
const dirty = !!draft && JSON.stringify(draft) !== JSON.stringify(role);
const toggle = (pid: string, on: boolean) => draft && setDraft({ ...draft, permissions: on ? [...new Set([...draft.permissions, pid])] : draft.permissions.filter((x) => x !== pid) });
if (!shown) return null;
const editing = !!draft;
const has = (pid: string) => shown.locked || shown.permissions.includes(pid);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button
size="sm"
iconLeft="plus"
onClick={() => {
const nr: TeamRole = { id: `r${Date.now()}`, name: t.newRoleName, description: '', permissions: [] };
setRoles((l) => [...l, nr]);
setCurrent(nr.id);
setDraft(nr);
setView('detail');
}}
>
{t.newRole}
</Button>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[15rem_minmax(0,1fr)] lg:grid-cols-[17rem_minmax(0,1fr)] md:items-start">
<nav aria-label={t.roles} className={cn('b5t-rise md:sticky md:top-6', view === 'detail' && 'max-md:hidden')}>
<ul
ref={listRef}
role="listbox"
aria-label={t.roles}
className="grid gap-1 rounded-xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-sm)]"
onKeyDown={(e) => {
const i = roles.findIndex((r) => r.id === current);
const n = e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? roles.length - 1 : null;
if (e.key === 'Enter') return setView('detail');
if (n === null) return;
e.preventDefault();
const k = Math.max(0, Math.min(roles.length - 1, n));
setCurrent(roles[k].id);
setDraft(null);
listRef.current?.querySelectorAll<HTMLElement>('[role=option]')[k]?.focus();
}}
>
{roles.map((r) => {
const on = r.id === current;
const n = people(r.id).length;
return (
<li key={r.id} role="option" aria-selected={on} tabIndex={on ? 0 : -1} onClick={() => choose(r.id)} className={cn('flex min-h-14 cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface', ringIn)}>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', on ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground')}>
<Icon name={r.locked ? 'crown' : 'shield'} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{r.name}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{n === 1 ? t.personOne : fill(t.peopleN, { n })}</span>
</span>
<Icon name="chevron-right" size={15} className="shrink-0 text-subtle-foreground md:hidden" />
</li>
);
})}
</ul>
</nav>
<section aria-labelledby={`${id}-h`} className={cn('grid min-w-0 gap-4', view === 'list' && 'max-md:hidden')}>
<button type="button" onClick={() => setView('list')} className={cn('-ms-2 flex h-11 w-fit items-center gap-0.5 rounded-lg pe-2.5 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{t.roles}
</button>
<div key={shown.id} className="b5t-in rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
{editing ? (
<div className="grid gap-3 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.name}
<input id={`${id}-h`} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} className={field} />
</label>
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.description}
<input value={draft.description ?? ''} onChange={(e) => setDraft({ ...draft, description: e.target.value })} className={field} />
</label>
</div>
) : (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 id={`${id}-h`} className="flex items-center gap-2 text-[19px] font-semibold tracking-[-0.02em] text-foreground">
{shown.name}
{shown.locked && <Icon name="lock" size={15} className="text-subtle-foreground" label={t.locked} />}
</h2>
{shown.description && <p className="mt-1 text-pretty text-[13.5px] text-muted-foreground">{shown.description}</p>}
{shown.locked && <p className="mt-2 text-[12.5px] text-muted-foreground">{t.locked}</p>}
</div>
{!shown.locked && (
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
iconLeft="copy"
onClick={() => {
const copy: TeamRole = { ...shown, id: `r${Date.now()}`, name: fill(t.copyOf, { name: shown.name }), locked: false };
setRoles((l) => [...l, copy]);
setCurrent(copy.id);
setDraft(copy);
}}
>
{t.duplicate}
</Button>
<Button size="sm" iconLeft="edit" onClick={() => (setDraft({ ...shown }), setState('idle'))}>
{t.edit}
</Button>
</div>
)}
</div>
)}
{state === 'saved' && !editing && (
<p className="b5t-in mt-3 flex items-center gap-2 text-[13px] text-success-foreground">
<Icon name="check-circle" size={15} />
{t.saved}
</p>
)}
</div>
<div className="grid gap-4 xl:grid-cols-2">
{groups.map((g, gi) => {
const list = permissions.filter((p) => p.group === g);
const on = list.filter((p) => has(p.id)).length;
return (
<section key={g} className="b5t-rise overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={{ '--b5t-d': `${80 + gi * 60}ms` } as React.CSSProperties}>
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-2.5 sm:px-5">
{editing && (
<label className="-ms-2 grid size-9 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.allIn, { group: g })}</span>
<CheckboxBox checked={on === list.length} indeterminate={on > 0 && on < list.length} onChange={() => setDraft({ ...draft!, permissions: on === list.length ? draft!.permissions.filter((x) => !list.some((p) => p.id === x)) : [...new Set([...draft!.permissions, ...list.map((p) => p.id)])] })} />
</label>
)}
<h3 className="flex-1 text-[13px] font-semibold text-foreground">{g}</h3>
<span className="text-[12px] tabular-nums text-muted-foreground">{fill(t.ofN, { n: on, total: list.length })}</span>
</div>
<ul>
{list.map((p) => {
const yes = has(p.id);
const inner = (
<>
<span className="min-w-0 flex-1">
<span className={cn('block text-[13.5px]', yes ? 'text-foreground' : 'text-muted-foreground')}>{p.label}</span>
{p.description && <span className="block text-pretty text-[12px] text-subtle-foreground">{p.description}</span>}
</span>
</>
);
return (
<li key={p.id} className="border-b border-border last:border-b-0">
{editing ? (
<label className="flex min-h-12 cursor-pointer items-center gap-3 px-4 py-2.5 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)] sm:px-5">
<CheckboxBox checked={yes} onChange={(e) => toggle(p.id, e.target.checked)} />
{inner}
</label>
) : (
<div className="flex min-h-12 items-center gap-3 px-4 py-2.5 sm:px-5">
<span className={cn('grid size-[18px] shrink-0 place-items-center rounded-full', yes ? 'bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground' : 'bg-muted text-subtle-foreground')}>
<Icon name={yes ? 'check' : 'minus'} size={11} strokeWidth={2.6} />
</span>
<span className="sr-only">{yes ? t.permYes : t.permNo}</span>
{inner}
</div>
)}
</li>
);
})}
</ul>
</section>
);
})}
</div>
<section className="b5t-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5t-d:240ms] sm:p-5">
<h3 className="text-[13px] font-semibold text-foreground">{t.withRole}</h3>
{people(shown.id).length ? (
<ul className="mt-3 flex flex-wrap gap-2">
{people(shown.id).map((m) => (
<li key={m.id} className="inline-flex h-9 items-center gap-2 rounded-full border border-border bg-surface pe-3 ps-1 text-[13px] text-foreground">
<Avatar name={m.name} size="xs" alt="" className="size-7 text-[10px]" />
{m.name}
</li>
))}
</ul>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">{t.noneWithRole}</p>
)}
</section>
{editing && (
<div className="b5t-in sticky bottom-3 z-10 flex flex-col-reverse gap-2 rounded-xl border border-border bg-popover p-3 shadow-[var(--highlight),var(--shadow-lg)] sm:flex-row sm:justify-end">
<Button
variant="ghost"
onClick={() => {
if (!roles.find((r) => r.id === draft.id)?.name || (draft.permissions.length === 0 && !role?.permissions.length && role?.name === t.newRoleName)) {
setRoles((l) => l.filter((r) => r.id !== draft.id || r.permissions.length || r.name !== t.newRoleName));
}
setDraft(null);
}}
>
{t.discard}
</Button>
<Button
iconLeft={state === 'busy' ? 'loader' : 'check'}
disabled={state === 'busy' || !draft.name.trim() || (!dirty && draft.name !== t.newRoleName)}
onClick={async () => {
setState('busy');
await run(onAction, 'save-role', { role: draft });
setRoles((l) => l.map((r) => (r.id === draft.id ? draft : r)));
setDraft(null);
setState('saved');
setLive(t.saved);
}}
>
{state === 'busy' ? t.saving : t.save}
</Button>
</div>
)}
</section>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* teams */
/* ------------------------------------------------------------------ */
function Teams(c: Ctx) {
const { t, members = [], onAction } = c;
const [teams, setTeams] = useState(c.teams ?? []);
const roots = teams.filter((x) => !x.parent || !teams.some((y) => y.id === x.parent));
const kids = (pid: string) => teams.filter((x) => x.parent === pid);
const [open, setOpen] = useState<string[]>(() => roots.map((r) => r.id));
const [current, setCurrent] = useState(teams[1]?.id ?? teams[0]?.id ?? '');
const [focus, setFocus] = useState(current);
const [view, setView] = useState<'list' | 'detail'>('detail');
const [add, setAdd] = useState('');
const [live, setLive] = useState('');
const tree = useRef<HTMLUListElement>(null);
const person = (id?: string) => members.find((m) => m.id === id);
const team = teams.find((x) => x.id === current);
// Visible nodes in order, for the arrow keys
const visible: { node: TeamGroup; level: number }[] = [];
const walk = (list: TeamGroup[], level: number) =>
list.forEach((n) => {
visible.push({ node: n, level });
if (open.includes(n.id)) walk(kids(n.id), level + 1);
});
walk(roots, 1);
const total = (id: string): number => {
const set = new Set<string>();
const go = (tid: string) => {
teams.find((x) => x.id === tid)?.members.forEach((m) => set.add(m));
kids(tid).forEach((k) => go(k.id));
};
go(id);
return set.size;
};
const move = (id: string) => {
setFocus(id);
tree.current?.querySelector<HTMLElement>(`[data-node="${id}"]`)?.focus();
};
const onKey = (e: React.KeyboardEvent) => {
const i = visible.findIndex((v) => v.node.id === focus);
const cur = visible[i];
if (!cur) return;
const k = e.key;
const hasKids = kids(cur.node.id).length > 0;
const isOpen = open.includes(cur.node.id);
if (k === 'ArrowDown') move(visible[Math.min(visible.length - 1, i + 1)].node.id);
else if (k === 'ArrowUp') move(visible[Math.max(0, i - 1)].node.id);
else if (k === 'Home') move(visible[0].node.id);
else if (k === 'End') move(visible[visible.length - 1].node.id);
else if (k === 'ArrowRight') {
if (hasKids && !isOpen) setOpen((o) => [...o, cur.node.id]);
else if (hasKids) move(kids(cur.node.id)[0].id);
} else if (k === 'ArrowLeft') {
if (hasKids && isOpen) setOpen((o) => o.filter((x) => x !== cur.node.id));
else if (cur.node.parent) move(cur.node.parent);
} else if (k === 'Enter' || k === ' ') {
setCurrent(cur.node.id);
setView('detail');
} else if (k === '*') setOpen(teams.map((x) => x.id));
else return;
e.preventDefault();
};
const update = (id: string, fn: (g: TeamGroup) => TeamGroup) => setTeams((l) => l.map((g) => (g.id === id ? fn(g) : g)));
const node_ = (node: TeamGroup, level: number) => {
const sub = kids(node.id);
const isOpen = open.includes(node.id);
const lead = person(node.lead);
return (
<li
key={node.id}
role="treeitem"
aria-level={level}
aria-expanded={sub.length ? isOpen : undefined}
aria-selected={node.id === current}
data-node={node.id}
tabIndex={node.id === focus ? 0 : -1}
onFocus={(e) => e.target === e.currentTarget && setFocus(node.id)}
onClick={(e) => {
e.stopPropagation();
setCurrent(node.id);
setFocus(node.id);
setView('detail');
}}
className={cn('rounded-lg', ringIn)}
>
<div className={cn('group/node flex min-h-12 cursor-pointer items-center gap-2 rounded-lg py-1.5 pe-2 transition-colors', node.id === current ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface')} style={{ paddingInlineStart: `${(level - 1) * 1.25 + 0.25}rem` }}>
{sub.length ? (
<span
aria-hidden
onClick={(e) => {
e.stopPropagation();
setOpen((o) => (isOpen ? o.filter((x) => x !== node.id) : [...o, node.id]));
}}
className="relative grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-2 hover:bg-accent hover:text-foreground"
>
<Icon name="chevron-right" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', isOpen && 'rotate-90')} />
</span>
) : (
<span className="w-7 shrink-0" />
)}
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', level === 1 ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--surface-2))] ' + accentText : 'bg-surface-2 text-muted-foreground')}>
<Icon name={level === 1 ? 'building' : 'users'} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{node.name}</span>
<span className="block truncate text-[12px] text-muted-foreground">
{lead ? lead.name : t.noLead} · {total(node.id) === 1 ? t.personOne : fill(t.peopleN, { n: total(node.id) })}
</span>
</span>
</div>
{sub.length > 0 && isOpen && (
<ul role="group" className="b5t-in relative mt-0.5 grid gap-0.5">
<span aria-hidden className="absolute bottom-3 top-0 w-px bg-border" style={{ insetInlineStart: `${(level - 1) * 1.25 + 1.125}rem` }} />
{sub.map((s) => node_(s, level + 1))}
</ul>
)}
</li>
);
};
const outside = members.filter((m) => team && !team.members.includes(m.id));
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[17rem_minmax(0,1fr)] lg:grid-cols-[20rem_minmax(0,1fr)] md:items-start">
<div className={cn('b5t-rise rounded-xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-sm)] md:sticky md:top-6', view === 'detail' && 'max-md:hidden')}>
<div className="flex items-center justify-between px-2.5 py-1.5">
<h2 className="text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.teams}</h2>
<Button size="sm" variant="ghost" onClick={() => setOpen(teams.map((x) => x.id))}>
{t.expandAll}
</Button>
</div>
<ul ref={tree} role="tree" aria-label={t.teams} onKeyDown={onKey} className="grid gap-0.5">
{roots.map((r) => node_(r, 1))}
</ul>
</div>
{team && (
<section aria-label={team.name} className={cn('grid min-w-0 gap-4', view === 'list' && 'max-md:hidden')}>
<button type="button" onClick={() => setView('list')} className={cn('-ms-2 flex h-11 w-fit items-center gap-0.5 rounded-lg pe-2.5 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{t.teams}
</button>
<div key={team.id} className="b5t-in rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
{team.parent && <p className="text-[12.5px] text-muted-foreground">{teams.find((x) => x.id === team.parent)?.name}</p>}
<h2 className="text-[20px] font-semibold tracking-[-0.025em] text-foreground">{team.name}</h2>
{team.description && <p className="mt-1 max-w-[42rem] text-pretty text-[13.5px] text-muted-foreground">{team.description}</p>}
<div className="mt-4 grid gap-3 border-t border-border pt-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)] sm:items-end md:grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div className="flex items-center gap-3">
{person(team.lead) ? <Avatar name={person(team.lead)!.name} size="md" alt="" /> : <span className="grid size-10 place-items-center rounded-full border border-dashed border-border-strong text-subtle-foreground"><Icon name="user" size={16} /></span>}
<div className="min-w-0">
<p className="text-[12px] font-medium text-muted-foreground">{t.lead}</p>
<p className="truncate text-[14px] font-semibold text-foreground">{person(team.lead)?.name ?? t.noLead}</p>
</div>
</div>
<Pick
label={t.changeLead}
hideLabel={false}
value={team.lead ?? ''}
onChange={(v) => {
update(team.id, (g) => ({ ...g, lead: v, members: g.members.includes(v) ? g.members : [...g.members, v] }));
setLive(fill(t.leadSet, { name: person(v)?.name ?? '', team: team.name }));
void run(onAction, 'lead', { team: team.id, lead: v });
}}
>
{!team.lead && <option value="">{t.noLead}</option>}
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Pick>
</div>
</div>
{kids(team.id).length > 0 && (
<div className="b5t-rise [--b5t-d:80ms]">
<h3 className="mb-2 text-[13px] font-semibold text-foreground">{t.subTeams}</h3>
<ul className="grid gap-2 sm:grid-cols-2">
{kids(team.id).map((k) => (
<li key={k.id}>
<button
type="button"
onClick={() => {
setCurrent(k.id);
setFocus(k.id);
setOpen((o) => [...new Set([...o, team.id])]);
}}
className={cn('group/sub flex w-full items-center gap-3 rounded-xl border border-border bg-card p-3 text-left shadow-[var(--shadow-sm)] transition-[border-color,translate] duration-200 hover:-translate-y-0.5 hover:border-border-strong', ring)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
<Icon name="users" size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{k.name}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{total(k.id) === 1 ? t.personOne : fill(t.peopleN, { n: total(k.id) })}</span>
</span>
<Icon name="arrow-right" size={15} className="text-subtle-foreground transition-transform group-hover/sub:translate-x-0.5" />
</button>
</li>
))}
</ul>
</div>
)}
<Card
title={t.members}
delay={140}
bodyClassName="p-0 sm:p-0"
action={
<div className="flex w-full items-center gap-2 xl:w-auto">
<Pick label={t.pickPerson} value={add} onChange={setAdd} className="min-w-0 flex-1 xl:w-48 xl:flex-none">
<option value="">{t.pickPerson}</option>
{outside.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Pick>
<Button
size="sm"
variant="outline"
iconLeft="plus"
disabled={!add}
className="sm:h-9"
onClick={() => {
update(team.id, (g) => ({ ...g, members: [...g.members, add] }));
setLive(fill(t.added, { name: person(add)?.name ?? '', team: team.name }));
void run(onAction, 'add-member', { team: team.id, member: add });
setAdd('');
}}
>
{t.addMember}
</Button>
</div>
}
>
<ul>
{team.members.map((mid, i) => {
const m = person(mid);
if (!m) return null;
const lead = mid === team.lead;
return (
<li key={mid} className="b5t-rise flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ '--b5t-d': `${180 + i * 35}ms` } as React.CSSProperties}>
<Avatar name={m.name} size="sm" alt="" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
{lead && <RoleBadge name={t.lead} strong />}
</span>
<span className="block truncate text-[12.5px] text-muted-foreground">{m.email}</span>
</span>
{!lead && (
<IconButton
icon="user-minus"
size="sm"
label={fill(t.removeName, { name: m.name })}
onClick={() => {
update(team.id, (g) => ({ ...g, members: g.members.filter((x) => x !== mid) }));
setLive(fill(t.removedFrom, { name: m.name, team: team.name }));
void run(onAction, 'remove-member', { team: team.id, member: mid });
}}
className="hover:text-destructive"
/>
)}
</li>
);
})}
</ul>
</Card>
</section>
)}
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* seats */
/* ------------------------------------------------------------------ */
function Seats(c: Ctx) {
const { t, locale, now, roles = [], onAction } = c;
const seats = c.seats ?? { total: 10, price: 12, renews: now.slice(0, 10) };
const [total, setTotal] = useState(seats.total);
const [members, setMembers] = useState(c.members ?? []);
const [extra, setExtra] = useState(3);
const [busy, setBusy] = useState(false);
const [live, setLive] = useState('');
const invites = c.invites ?? [];
const activeN = members.filter((m) => (m.status ?? 'active') === 'active').length;
const invitedN = members.filter((m) => m.status === 'invited').length + invites.length;
const used = activeN + invitedN;
const free = Math.max(0, total - used);
const share = (n: number) => `${Math.round((n / Math.max(total, used)) * 1000) / 10}%`;
const interval = seats.interval === 'year' ? t.year : t.month;
const cur = seats.currency ?? 'EUR';
const periodDays = seats.interval === 'year' ? 365 : 30;
const left = Math.max(0, Math.min(periodDays, days(now.slice(0, 10), seats.renews)));
const today = Math.round(((extra * seats.price * left) / periodDays) * 100) / 100;
const away = c.inactiveDays ?? 30;
const idle = members.filter((m) => (m.status ?? 'active') === 'active' && m.lastActive && days(m.lastActive, now) >= away).sort((a, b) => toMs(a.lastActive!) - toMs(b.lastActive!));
const byRole = roles.map((r) => ({ r, n: members.filter((m) => m.role === r.id && m.status !== 'suspended').length })).filter((x) => x.n);
const max = Math.max(1, ...byRole.map((x) => x.n));
const id = useId();
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<span className="text-[13px] text-muted-foreground">{fill(t.renews, { date: dateFmt(seats.renews, locale) })}</span>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:items-start">
<div className="grid gap-6">
<Card delay={60}>
<p className="text-[13px] font-medium text-muted-foreground">{t.seats}</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-[clamp(2rem,1.6rem+1.6vw,2.75rem)] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">
{used}
<span className="text-muted-foreground">/{total}</span>
</span>
<span className={cn('text-[14px] font-medium', free ? 'text-success-foreground' : 'text-warning-foreground')}>{free ? fill(t.seatsFree, { n: free }) : t.seatsFull}</span>
</p>
<p className="sr-only">{fill(t.seatsUsed, { used, total })}</p>
<div aria-hidden className="mt-5 flex h-3 overflow-hidden rounded-full bg-track">
<span key={`a${activeN}${total}`} className="b5t-fill h-full bg-primary" style={{ width: share(activeN) }} />
<span key={`i${invitedN}${total}`} className="b5t-fill h-full border-s-2 border-card bg-[color-mix(in_oklab,var(--primary)_40%,var(--card))] [--b5t-d:350ms]" style={{ width: share(invitedN) }} />
</div>
<ul className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
{[
{ label: t.activeSeats, n: activeN, dot: 'bg-primary' },
{ label: t.invitedSeats, n: invitedN, dot: 'bg-[color-mix(in_oklab,var(--primary)_40%,var(--card))]' },
{ label: t.freeSeats, n: free, dot: 'bg-track' },
].map((x) => (
<li key={x.label} className="flex items-center gap-2 text-muted-foreground">
<span className={cn('size-2.5 rounded-full', x.dot)} />
{x.label}
<span className="font-semibold tabular-nums text-foreground">{x.n}</span>
</li>
))}
</ul>
</Card>
<Card title={t.byRole} delay={120}>
<ul className="grid gap-3.5">
{byRole.map(({ r, n }, i) => (
<li key={r.id} className="grid grid-cols-[7rem_minmax(0,1fr)_2rem] items-center gap-3 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)_2rem]">
<span className="truncate text-foreground">{r.name}</span>
<span className="h-2 overflow-hidden rounded-full bg-track">
<span className="b5t-fill block h-full rounded-full bg-[color-mix(in_oklab,var(--primary)_75%,var(--card))]" style={{ width: `${Math.round((n / max) * 100)}%`, '--b5t-d': `${200 + i * 80}ms` } as React.CSSProperties} />
</span>
<span className="text-right font-semibold tabular-nums text-foreground">{n}</span>
</li>
))}
</ul>
</Card>
<Card title={t.inactive} description={t.inactiveText} delay={180} bodyClassName="p-0 sm:p-0">
{idle.length ? (
<ul>
{idle.map((m) => (
<li key={m.id} className="b5t-in flex flex-wrap items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5">
<Avatar name={m.name} size="sm" alt="" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
<span className="block text-[12.5px] text-warning-foreground">{fill(t.awayFor, { n: days(m.lastActive!, now) })}</span>
</span>
<Button
size="sm"
variant="outline"
onClick={() => {
setMembers((l) => l.map((x) => (x.id === m.id ? { ...x, status: 'suspended' } : x)));
setLive(fill(t.freed, { name: m.name }));
void run(onAction, 'free-seat', { id: m.id });
}}
>
{t.free}
</Button>
</li>
))}
</ul>
) : (
<p className="px-5 py-8 text-center text-[13.5px] text-muted-foreground">{t.noInactive}</p>
)}
</Card>
</div>
<Card title={t.addSeats} delay={140} className="lg:sticky lg:top-6">
<div className="grid gap-5">
<div>
<label htmlFor={`${id}-n`} className="text-[13px] font-medium text-foreground">
{t.seatsToAdd}
</label>
<div className="mt-2 flex items-center gap-2">
<IconButton icon="minus" variant="outline" label={t.fewer} disabled={extra <= 1} onClick={() => setExtra((n) => Math.max(1, n - 1))} />
<input
id={`${id}-n`}
type="number"
inputMode="numeric"
min={1}
max={500}
value={extra}
onChange={(e) => setExtra(Math.max(1, Math.min(500, Math.round(Number(e.target.value) || 1))))}
className={cn(field, 'h-11 w-20 text-center text-[17px] font-semibold tabular-nums sm:h-11 sm:text-[17px]')}
/>
<IconButton icon="plus" variant="outline" label={t.more} onClick={() => setExtra((n) => Math.min(500, n + 1))} />
</div>
<p className="mt-2 text-[12.5px] text-muted-foreground">{fill(t.perSeat, { price: money(seats.price, locale, cur), interval })}</p>
</div>
<dl className="grid gap-2 rounded-lg bg-surface p-3.5 text-[13px]">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground">{fill(t.newTotal, { n: total + extra, price: money((total + extra) * seats.price, locale, cur), interval })}</dt>
</div>
<div key={extra} className="b5t-in flex items-baseline justify-between gap-3 border-t border-border pt-2">
<dt className="text-pretty text-foreground">{fill(t.today, { date: dateFmt(seats.renews, locale) })}</dt>
<dd className="shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{money(today, locale, cur)}</dd>
</div>
</dl>
<Button
iconLeft={busy ? 'loader' : 'plus'}
disabled={busy}
onClick={async () => {
setBusy(true);
await run(onAction, 'add-seats', { seats: extra }, 900);
setTotal((n) => n + extra);
setBusy(false);
setLive(fill(t.bought, { n: extra }));
}}
>
{busy ? t.buying : extra === 1 ? t.buyOne : fill(t.buy, { n: extra })}
</Button>
</div>
</Card>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* TeamManagement */
/* ------------------------------------------------------------------ */
/**
* Team screens for apps: members in a table with roles and a careful remove,
* invitations with checked email chips and a join link, roles with their rights,
* teams as a tree with leads, and seats with the cost of more.
* <TeamManagement variant="members" title="Team" members={[…]} roles={[…]} />
*/
export function TeamManagement({ variant = 'members', sidebar, labels, className, now = '2026-10-23T14:30', ...props }: TeamManagementProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const c: Ctx = { ...props, t, locale, now };
const body = (() => {
switch (variant) {
case 'invite':
return <Invite {...c} />;
case 'roles':
return <Roles {...c} />;
case 'teams':
return <Teams {...c} />;
case 'seats':
return <Seats {...c} />;
default:
return <Members {...c} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Teamverwaltung?
Mit aiw add @ai-website/team-management. Der Befehl legt die Datei components/ui/team-management.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, checkbox, dialog, icon, icon-button, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Teamverwaltung?
5 Varianten, Mitglieder, Personen einladen, Rollen und Rechte, Teams und Plätze. Du wählst sie mit der Prop variant, zum Beispiel variant="invite".
Kann ich die Texte von Teamverwaltung übersetzen?
Ja. Teamverwaltung bringt 140 eigene Texte auf Deutsch und Englisch mit, etwa „name@firma.de“, „Erlaubt,“ und „Nicht Teil dieser Rolle,“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Teamverwaltung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Teamverwaltung mit aiw_components und installieren sie mit aiw_add @ai-website/team-management. Nenn im Prompt die Variante, etwa variant="members", dann schreibt der Assistent deine Inhalte direkt in die Props.