Zum Inhalt springen
UI Kit
Accent

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.

5 VariantsEine von 4 Components in Team und Zusammenarbeit

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

MitgliederNeu in 1.2

Eine Tabelle mit Suche, Rollenfilter und Status, Rolle je Person, Sammeländerungen und ein Entfernen mit Rückfrage und Rückgängig. Telefone bekommen Karten.

Personen einladenNeu in 1.2

E-Mail-Adressen als geprüfte Chips, die Rolle als Karten, offene Einladungen erneut senden oder zurückziehen und ein Beitrittslink zum Kopieren.

Rollen und RechteNeu in 1.2

Rollen in einer Liste mit ihren Personen, Rechte nach Gruppen mit Zählern, Bearbeiten, Duplizieren und neue Rolle. Die Inhaberrolle bleibt gesperrt.

TeamsNeu in 1.2

Abteilungen als Baum mit Pfeiltasten, Leitung und Köpfe je Team, Teams darin und Mitglieder zum Hinzufügen oder Entfernen.

PlätzeNeu in 1.2

Belegte, durch Einladungen gehaltene und freie Plätze, Plätze nach Rolle, länger abwesende Personen zum Freigeben und neue Plätze mit dem heutigen Betrag.

Details

Was Teamverwaltung mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle TeamManagementProps aus components/ui/team-management.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    TeamManagementVariant

    membersinviterolesteamsseats

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • meoptional
    string

    Your member id

  • nowoptional
    string

    ISO date and time that counts as now

  • membersoptional
    TeamMember[]
  • rolesoptional
    TeamRole[]
  • permissionsoptional
    TeamPermission[]
  • invitesoptional
    TeamInvite[]
  • teamsoptional
    TeamGroup[]
  • seatsoptional
    TeamSeats
  • inviteLinkoptional
    string

    invite: the join link

  • inviteDaysoptional
    number

    invite: days until an invitation runs out

  • inactiveDaysoptional
    number

    seats: 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
emailHintEnglisch name@company.comDeutsch name@firma.de
permYesEnglisch Allowed,Deutsch Erlaubt,
permNoEnglisch Not part of this role,Deutsch Nicht Teil dieser Rolle,
invitePeopleEnglisch Invite peopleDeutsch Personen einladen
searchEnglisch Search by name or emailDeutsch Nach Name oder E-Mail suchen
allRolesEnglisch All rolesDeutsch Alle Rollen
roleEnglisch RoleDeutsch Rolle
statusEnglisch StatusDeutsch Status
allEnglisch AllDeutsch Alle
activeEnglisch ActiveDeutsch Aktiv
invitedEnglisch InvitedDeutsch Eingeladen
suspendedEnglisch PausedDeutsch Pausiert
memberEnglisch MemberDeutsch Mitglied
lastActiveEnglisch Last activeDeutsch Zuletzt aktiv
neverEnglisch Not yetDeutsch Noch nicht
twoFactorEnglisch Two step sign inDeutsch Anmeldung in zwei Schritten
twoOnEnglisch Two step sign in is onDeutsch Anmeldung in zwei Schritten ist an
twoOffEnglisch Two step sign in is offDeutsch Anmeldung in zwei Schritten ist aus
youEnglisch youDeutsch du
countEnglisch {n} of {total} peopleDeutsch {n} von {total} Personen
selectedEnglisch {n} selectedDeutsch {n} ausgewählt
selectAllEnglisch Select every person shownDeutsch Alle angezeigten Personen auswählen
selectEnglisch Select {name}Deutsch {name} auswählen
changeRoleEnglisch Change role toDeutsch Rolle ändern auf
removeEnglisch RemoveDeutsch Entfernen
removeNameEnglisch Remove {name}Deutsch {name} entfernen
removeTitleEnglisch Remove {name} from the team?Deutsch {name} aus dem Team entfernen?
removeManyEnglisch Remove {n} people from the team?Deutsch {n} Personen aus dem Team entfernen?
removeTextEnglisch 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.
removeConfirmEnglisch Remove from teamDeutsch Aus dem Team entfernen
cancelEnglisch CancelDeutsch Abbrechen
removedEnglisch {name} was removed.Deutsch {name} wurde entfernt.
removedManyEnglisch {n} people were removed.Deutsch {n} Personen wurden entfernt.
undoEnglisch UndoDeutsch Rückgängig
restoredEnglisch Back in the team.Deutsch Wieder im Team.
roleChangedEnglisch {name} is now {role}.Deutsch {name} ist jetzt {role}.
lockedEnglisch The owner keeps every right.Deutsch Der Inhaber behält alle Rechte.
noMatchEnglisch Nobody matches. Try another name or role.Deutsch Niemand passt. Versuche einen anderen Namen oder eine andere Rolle.
emailsEnglisch Email addressesDeutsch E-Mail-Adressen
emailsHintEnglisch 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.
invalidEnglisch {n} addresses need a lookDeutsch {n} Adressen brauchen einen Blick
invalidOneEnglisch 1 address needs a lookDeutsch 1 Adresse braucht einen Blick
badEnglisch Not an email addressDeutsch Keine E-Mail-Adresse
dupEnglisch Already addedDeutsch Schon hinzugefügt
existsEnglisch Already in the teamDeutsch Schon im Team
pendingAlreadyEnglisch Already invitedDeutsch Schon eingeladen
messageEnglisch Personal noteDeutsch Persönliche Notiz
messageHintEnglisch Shown in the invitation emailDeutsch Steht in der Einladungsmail
optionalEnglisch optionalDeutsch optional
sendEnglisch Send invitationsDeutsch Einladungen senden
sendNEnglisch Send {n} invitationsDeutsch {n} Einladungen senden
sendOneEnglisch Send 1 invitationDeutsch 1 Einladung senden
sendingEnglisch Sending…Deutsch Wird gesendet…
sentNEnglisch {n} invitations are on their way.Deutsch {n} Einladungen sind unterwegs.
sentOneEnglisch The invitation is on its way.Deutsch Die Einladung ist unterwegs.
pendingEnglisch Pending invitationsDeutsch Offene Einladungen
noPendingEnglisch Every invitation was answered.Deutsch Alle Einladungen sind beantwortet.
sentByEnglisch Sent {when} by {name}Deutsch Gesendet {when} von {name}
sentAgoEnglisch Sent {when}Deutsch Gesendet {when}
expiresEnglisch Runs out {when}Deutsch Läuft ab {when}
expiredEnglisch Ran outDeutsch Abgelaufen
resendEnglisch Send againDeutsch Erneut senden
resendingEnglisch Sending…Deutsch Wird gesendet…
resentEnglisch Sent againDeutsch Erneut gesendet
resentNoteEnglisch Invitation to {email} sent again.Deutsch Einladung an {email} erneut gesendet.
revokeEnglisch WithdrawDeutsch Zurückziehen
revokeAskEnglisch Withdraw it?Deutsch Zurückziehen?
revokeYesEnglisch Yes, withdrawDeutsch Ja, zurückziehen
revokedEnglisch Invitation to {email} withdrawn.Deutsch Einladung an {email} zurückgezogen.
linkEnglisch Invite linkDeutsch Einladungslink
linkTextEnglisch Anyone with the link joins with the role you pick.Deutsch Wer den Link hat, tritt mit der gewählten Rolle bei.
linkOnEnglisch Link is onDeutsch Link ist an
linkOffEnglisch The link is off. Nobody can join with it.Deutsch Der Link ist aus. Niemand kann damit beitreten.
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch CopiedDeutsch Kopiert
copiedNoteEnglisch Link copied.Deutsch Link kopiert.
newLinkEnglisch Make a new linkDeutsch Neuen Link erstellen
newLinkNoteEnglisch New link made. The old one no longer works.Deutsch Neuer Link erstellt. Der alte gilt nicht mehr.
linkRoleEnglisch Joins asDeutsch Tritt bei als
rolesEnglisch RolesDeutsch Rollen
newRoleEnglisch New roleDeutsch Neue Rolle
newRoleNameEnglisch New roleDeutsch Neue Rolle
peopleNEnglisch {n} peopleDeutsch {n} Personen
personOneEnglisch 1 personDeutsch 1 Person
editEnglisch Edit roleDeutsch Rolle bearbeiten
duplicateEnglisch DuplicateDeutsch Duplizieren
copyOfEnglisch {name} copyDeutsch {name} Kopie
saveEnglisch Save roleDeutsch Rolle speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Role saved.Deutsch Rolle gespeichert.
discardEnglisch Discard changesDeutsch Änderungen verwerfen
nameEnglisch NameDeutsch Name
descriptionEnglisch DescriptionDeutsch Beschreibung
rightsEnglisch RightsDeutsch Rechte
ofNEnglisch {n} of {total}Deutsch {n} von {total}
allInEnglisch Every right in {group}Deutsch Alle Rechte in {group}
withRoleEnglisch People with this roleDeutsch Personen mit dieser Rolle
noneWithRoleEnglisch Nobody has this role yet.Deutsch Noch hat niemand diese Rolle.
teamsEnglisch TeamsDeutsch Teams
expandAllEnglisch Expand allDeutsch Alle aufklappen
leadEnglisch LeadDeutsch Leitung
noLeadEnglisch No lead yetDeutsch Noch keine Leitung
changeLeadEnglisch Change leadDeutsch Leitung ändern
membersEnglisch MembersDeutsch Mitglieder
addMemberEnglisch Add to teamDeutsch Zum Team hinzufügen
pickPersonEnglisch Pick a personDeutsch Person wählen
addedEnglisch {name} joined {team}.Deutsch {name} ist jetzt in {team}.
removedFromEnglisch {name} left {team}.Deutsch {name} hat {team} verlassen.
leadSetEnglisch {name} now leads {team}.Deutsch {name} leitet jetzt {team}.
subTeamsEnglisch Teams insideDeutsch Teams darin
backEnglisch BackDeutsch Zurück
seatsEnglisch SeatsDeutsch Plätze
seatsUsedEnglisch {used} of {total} seats in useDeutsch {used} von {total} Plätzen belegt
seatsFreeEnglisch {n} freeDeutsch {n} frei
seatsFullEnglisch Every seat is takenDeutsch Alle Plätze sind belegt
seatsPendingEnglisch {n} held by invitationsDeutsch {n} durch Einladungen belegt
activeSeatsEnglisch Active membersDeutsch Aktive Mitglieder
invitedSeatsEnglisch Open invitationsDeutsch Offene Einladungen
freeSeatsEnglisch FreeDeutsch Frei
byRoleEnglisch Seats by roleDeutsch Plätze nach Rolle
addSeatsEnglisch Add seatsDeutsch Plätze hinzufügen
seatsToAddEnglisch Seats to addDeutsch Neue Plätze
fewerEnglisch One seat lessDeutsch Ein Platz weniger
moreEnglisch One seat moreDeutsch Ein Platz mehr
perSeatEnglisch {price} per seat and {interval}Deutsch {price} je Platz und {interval}
monthEnglisch monthDeutsch Monat
yearEnglisch yearDeutsch Jahr
newTotalEnglisch New total {n} seats, {price} per {interval}Deutsch Neu {n} Plätze, {price} je {interval}
todayEnglisch Due today, for the days until {date}Deutsch Heute fällig, für die Tage bis {date}
buyEnglisch Add {n} seatsDeutsch {n} Plätze hinzufügen
buyOneEnglisch Add 1 seatDeutsch 1 Platz hinzufügen
buyingEnglisch Adding…Deutsch Wird hinzugefügt…
boughtEnglisch {n} seats added. They are ready to use.Deutsch {n} Plätze hinzugefügt. Sie sind sofort nutzbar.
inactiveEnglisch Seats you can freeDeutsch Plätze, die du freigeben kannst
inactiveTextEnglisch These people have not signed in for a while.Deutsch Diese Personen haben sich länger nicht angemeldet.
awayForEnglisch Away for {n} daysDeutsch Seit {n} Tagen weg
freeEnglisch Free up seatDeutsch Platz freigeben
freedEnglisch {name} is paused and the seat is free.Deutsch {name} ist pausiert, der Platz ist frei.
noInactiveEnglisch Everyone signed in lately. Every seat is in use.Deutsch Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt.
renewsEnglisch 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
components/ui/team-management.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert