Skip to content
UI Kit
Accent color

Team management UI

Team screens for apps. Members with roles and a careful remove, invitations with checked addresses and a link, roles with rights, teams as a tree and seats.

5 variantsOne of 4 components in Team and collaboration

People, roles and seats in order

Team management is the page an admin opens to bring someone in, change a role or free a seat. These five screens handle it with care, a remove asks first and can be undone, and every email address is checked before an invitation goes out.

Members shows a table with search, role filter and status, a role per person and bulk changes, phones get cards. Invite people takes email addresses as checked chips, a role as cards, lists open invitations to send again or withdraw and offers a join link to copy or renew. Roles and rights lists roles with their people and rights by group with counts, to edit, duplicate or add, the owner role stays locked. Teams shows departments as a tree you walk with the arrow keys, with the lead and head count per team. Seats counts seats in use, held by invitations and free, by role, points to people away for a while and adds seats with the cost due today.

Name roles after what people do, editor or billing, not after levels. Show the cost of a new seat before the button, not after. Let admins undo a remove for a few seconds, mistakes happen in long lists.

Variants

MembersNew in 1.2

A table with search, role filter and status, a role per person, bulk changes and a remove that asks first and can be undone. Phones get cards.

Invite peopleNew in 1.2

Email addresses as checked chips, a role as cards, open invitations to send again or withdraw and a join link to copy or renew.

Roles and rightsNew in 1.2

Roles in a list with their people, rights by group with counts, edit, duplicate and a new role. A locked owner role stays as it is.

TeamsNew in 1.2

Departments as a tree with arrow keys, the lead and head count per team, teams inside and members to add or remove.

SeatsNew in 1.2

Seats in use, held by invitations and free, seats by role, people away for a while to free up and more seats with the cost due today.

Details

What Team management brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/team-management.tsx
npm packages
None
Languages
English and German, 140 texts of its own
Studio
Adjust in code

Props

The TeamManagementProps interface from components/ui/team-management.tsx, with the comments from the code.

  • variantoptional
    TeamManagementVariant

    membersinviterolesteamsseats

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

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

  • titlerequired
    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.

Built-in texts

Team management writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 140 texts
emailHintEnglish name@company.comGerman name@firma.de
permYesEnglish Allowed,German Erlaubt,
permNoEnglish Not part of this role,German Nicht Teil dieser Rolle,
invitePeopleEnglish Invite peopleGerman Personen einladen
searchEnglish Search by name or emailGerman Nach Name oder E-Mail suchen
allRolesEnglish All rolesGerman Alle Rollen
roleEnglish RoleGerman Rolle
statusEnglish StatusGerman Status
allEnglish AllGerman Alle
activeEnglish ActiveGerman Aktiv
invitedEnglish InvitedGerman Eingeladen
suspendedEnglish PausedGerman Pausiert
memberEnglish MemberGerman Mitglied
lastActiveEnglish Last activeGerman Zuletzt aktiv
neverEnglish Not yetGerman Noch nicht
twoFactorEnglish Two step sign inGerman Anmeldung in zwei Schritten
twoOnEnglish Two step sign in is onGerman Anmeldung in zwei Schritten ist an
twoOffEnglish Two step sign in is offGerman Anmeldung in zwei Schritten ist aus
youEnglish youGerman du
countEnglish {n} of {total} peopleGerman {n} von {total} Personen
selectedEnglish {n} selectedGerman {n} ausgewählt
selectAllEnglish Select every person shownGerman Alle angezeigten Personen auswählen
selectEnglish Select {name}German {name} auswählen
changeRoleEnglish Change role toGerman Rolle ändern auf
removeEnglish RemoveGerman Entfernen
removeNameEnglish Remove {name}German {name} entfernen
removeTitleEnglish Remove {name} from the team?German {name} aus dem Team entfernen?
removeManyEnglish Remove {n} people from the team?German {n} Personen aus dem Team entfernen?
removeTextEnglish They lose access right away. Their work stays in the workspace and you can invite them again any time.German Der Zugang endet sofort. Ihre Arbeit bleibt im Workspace, und du kannst sie jederzeit wieder einladen.
removeConfirmEnglish Remove from teamGerman Aus dem Team entfernen
cancelEnglish CancelGerman Abbrechen
removedEnglish {name} was removed.German {name} wurde entfernt.
removedManyEnglish {n} people were removed.German {n} Personen wurden entfernt.
undoEnglish UndoGerman Rückgängig
restoredEnglish Back in the team.German Wieder im Team.
roleChangedEnglish {name} is now {role}.German {name} ist jetzt {role}.
lockedEnglish The owner keeps every right.German Der Inhaber behält alle Rechte.
noMatchEnglish Nobody matches. Try another name or role.German Niemand passt. Versuche einen anderen Namen oder eine andere Rolle.
emailsEnglish Email addressesGerman E-Mail-Adressen
emailsHintEnglish Separate them with a comma, a space or Enter. Pasting a list works too.German Mit Komma, Leerzeichen oder Enter trennen. Eine eingefügte Liste klappt auch.
invalidEnglish {n} addresses need a lookGerman {n} Adressen brauchen einen Blick
invalidOneEnglish 1 address needs a lookGerman 1 Adresse braucht einen Blick
badEnglish Not an email addressGerman Keine E-Mail-Adresse
dupEnglish Already addedGerman Schon hinzugefügt
existsEnglish Already in the teamGerman Schon im Team
pendingAlreadyEnglish Already invitedGerman Schon eingeladen
messageEnglish Personal noteGerman Persönliche Notiz
messageHintEnglish Shown in the invitation emailGerman Steht in der Einladungsmail
optionalEnglish optionalGerman optional
sendEnglish Send invitationsGerman Einladungen senden
sendNEnglish Send {n} invitationsGerman {n} Einladungen senden
sendOneEnglish Send 1 invitationGerman 1 Einladung senden
sendingEnglish Sending…German Wird gesendet…
sentNEnglish {n} invitations are on their way.German {n} Einladungen sind unterwegs.
sentOneEnglish The invitation is on its way.German Die Einladung ist unterwegs.
pendingEnglish Pending invitationsGerman Offene Einladungen
noPendingEnglish Every invitation was answered.German Alle Einladungen sind beantwortet.
sentByEnglish Sent {when} by {name}German Gesendet {when} von {name}
sentAgoEnglish Sent {when}German Gesendet {when}
expiresEnglish Runs out {when}German Läuft ab {when}
expiredEnglish Ran outGerman Abgelaufen
resendEnglish Send againGerman Erneut senden
resendingEnglish Sending…German Wird gesendet…
resentEnglish Sent againGerman Erneut gesendet
resentNoteEnglish Invitation to {email} sent again.German Einladung an {email} erneut gesendet.
revokeEnglish WithdrawGerman Zurückziehen
revokeAskEnglish Withdraw it?German Zurückziehen?
revokeYesEnglish Yes, withdrawGerman Ja, zurückziehen
revokedEnglish Invitation to {email} withdrawn.German Einladung an {email} zurückgezogen.
linkEnglish Invite linkGerman Einladungslink
linkTextEnglish Anyone with the link joins with the role you pick.German Wer den Link hat, tritt mit der gewählten Rolle bei.
linkOnEnglish Link is onGerman Link ist an
linkOffEnglish The link is off. Nobody can join with it.German Der Link ist aus. Niemand kann damit beitreten.
copyEnglish Copy linkGerman Link kopieren
copiedEnglish CopiedGerman Kopiert
copiedNoteEnglish Link copied.German Link kopiert.
newLinkEnglish Make a new linkGerman Neuen Link erstellen
newLinkNoteEnglish New link made. The old one no longer works.German Neuer Link erstellt. Der alte gilt nicht mehr.
linkRoleEnglish Joins asGerman Tritt bei als
rolesEnglish RolesGerman Rollen
newRoleEnglish New roleGerman Neue Rolle
newRoleNameEnglish New roleGerman Neue Rolle
peopleNEnglish {n} peopleGerman {n} Personen
personOneEnglish 1 personGerman 1 Person
editEnglish Edit roleGerman Rolle bearbeiten
duplicateEnglish DuplicateGerman Duplizieren
copyOfEnglish {name} copyGerman {name} Kopie
saveEnglish Save roleGerman Rolle speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Role saved.German Rolle gespeichert.
discardEnglish Discard changesGerman Änderungen verwerfen
nameEnglish NameGerman Name
descriptionEnglish DescriptionGerman Beschreibung
rightsEnglish RightsGerman Rechte
ofNEnglish {n} of {total}German {n} von {total}
allInEnglish Every right in {group}German Alle Rechte in {group}
withRoleEnglish People with this roleGerman Personen mit dieser Rolle
noneWithRoleEnglish Nobody has this role yet.German Noch hat niemand diese Rolle.
teamsEnglish TeamsGerman Teams
expandAllEnglish Expand allGerman Alle aufklappen
leadEnglish LeadGerman Leitung
noLeadEnglish No lead yetGerman Noch keine Leitung
changeLeadEnglish Change leadGerman Leitung ändern
membersEnglish MembersGerman Mitglieder
addMemberEnglish Add to teamGerman Zum Team hinzufügen
pickPersonEnglish Pick a personGerman Person wählen
addedEnglish {name} joined {team}.German {name} ist jetzt in {team}.
removedFromEnglish {name} left {team}.German {name} hat {team} verlassen.
leadSetEnglish {name} now leads {team}.German {name} leitet jetzt {team}.
subTeamsEnglish Teams insideGerman Teams darin
backEnglish BackGerman Zurück
seatsEnglish SeatsGerman Plätze
seatsUsedEnglish {used} of {total} seats in useGerman {used} von {total} Plätzen belegt
seatsFreeEnglish {n} freeGerman {n} frei
seatsFullEnglish Every seat is takenGerman Alle Plätze sind belegt
seatsPendingEnglish {n} held by invitationsGerman {n} durch Einladungen belegt
activeSeatsEnglish Active membersGerman Aktive Mitglieder
invitedSeatsEnglish Open invitationsGerman Offene Einladungen
freeSeatsEnglish FreeGerman Frei
byRoleEnglish Seats by roleGerman Plätze nach Rolle
addSeatsEnglish Add seatsGerman Plätze hinzufügen
seatsToAddEnglish Seats to addGerman Neue Plätze
fewerEnglish One seat lessGerman Ein Platz weniger
moreEnglish One seat moreGerman Ein Platz mehr
perSeatEnglish {price} per seat and {interval}German {price} je Platz und {interval}
monthEnglish monthGerman Monat
yearEnglish yearGerman Jahr
newTotalEnglish New total {n} seats, {price} per {interval}German Neu {n} Plätze, {price} je {interval}
todayEnglish Due today, for the days until {date}German Heute fällig, für die Tage bis {date}
buyEnglish Add {n} seatsGerman {n} Plätze hinzufügen
buyOneEnglish Add 1 seatGerman 1 Platz hinzufügen
buyingEnglish Adding…German Wird hinzugefügt…
boughtEnglish {n} seats added. They are ready to use.German {n} Plätze hinzugefügt. Sie sind sofort nutzbar.
inactiveEnglish Seats you can freeGerman Plätze, die du freigeben kannst
inactiveTextEnglish These people have not signed in for a while.German Diese Personen haben sich länger nicht angemeldet.
awayForEnglish Away for {n} daysGerman Seit {n} Tagen weg
freeEnglish Free up seatGerman Platz freigeben
freedEnglish {name} is paused and the seat is free.German {name} ist pausiert, der Platz ist frei.
noInactiveEnglish Everyone signed in lately. Every seat is in use.German Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt.
renewsEnglish Renews {date}German Verlängert sich am {date}

Accessibility

Read from the source of the component.

  • Team management handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab, Backspace and Space itself.
  • Sets aria-checked, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-level, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles alertdialog, group, listbox, option, radio, radiogroup, status, tree and treeitem.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,900 lines
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>
  );
}

Frequent questions

How do I install Team management?

With aiw add @ai-website/team-management. The command puts components/ui/team-management.tsx into your project, the code is yours from then on. It brings along avatar, button, checkbox, dialog, icon, icon-button, switch and sidebar. It needs no further npm packages.

Which variants does Team management have?

5 variants, Members, Invite people, Roles and rights, Teams and Seats. You pick one with the variant prop, for example variant="invite".

Can I translate the texts of Team management?

Yes. Team management carries 140 texts of its own in English and German, such as “name@company.com”, “Allowed,” and “Not part of this role,”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Team management?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Team management with aiw_components and install it with aiw_add @ai-website/team-management. Name the variant in your prompt, for example variant="members", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard