Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/TeamsPanel.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/TeamsPanel.tsx |
| Original SHA-256 | cdc7a02ef961f84ce3fd5c17bbb30bb33c8aa17e0796ee43f6e4b33301bf1499 |
| Git blob SHA | b20ff0f9c1e246268871301a36509535dab3ed65 |
| Reading-copy SHA-256 | cdc7a02ef961f84ce3fd5c17bbb30bb33c8aa17e0796ee43f6e4b33301bf1499 |
Recovered source transcript
Download complete source text ↓252 lines · 9.5 KB
import { useState } from 'react'
import { Archive, Crown, Plus, UserMinus, Users2 } from 'lucide-react'
import { Panel } from '../components/ui'
import { Empty, Note, Select, btnGhost, btnPrimary, fieldCls } from '../components/form'
import { CAP, can } from '../lib/can'
import { useAuth } from '../lib/useAuth'
import {
useAddTeamMember,
useCreateTeam,
useOrgSettings,
useRemoveTeamMember,
useTeams,
useUpdateTeam,
} from '../lib/settingsQueries'
import type { Team } from '../lib/settings'
// TEAMS, on the accounts screen rather than in settings.
//
// A team is an access grant when record scope is narrowed, so it belongs beside the roster that grants
// roles, not beside the toggles. The server agrees: reading teams needs candidate.read, writing them needs
// org.settings.write, the same authority as the setting that gives a team its meaning.
//
// Archived teams are hidden rather than listed greyed out. An archived desk grants nothing, so showing it
// among the live ones invites somebody to add a person to a team that will not widen what they can see.
export function TeamsPanel({ people }: { people: { id: string; full_name: string; role: string; status: string }[] }) {
const { principal } = useAuth()
const teams = useTeams()
const settings = useOrgSettings()
const create = useCreateTeam()
const [newName, setNewName] = useState('')
const mayWrite = can(principal, CAP.orgSettings)
const all = teams.data?.teams ?? []
const active = all.filter((t) => t.status === 'active')
const archivedCount = all.length - active.length
// Why teams matter right now, stated from the settings actually in force rather than in the abstract. An
// owner who has not narrowed anything is told that plainly, so they do not build an org chart the product
// is currently ignoring.
const s = settings.data?.settings
const scopeNarrowed = !!s && (s.staff_record_scope === 'team' || s.manager_record_scope === 'team')
const visibilityNarrowed = !!s && s.work_visibility === 'team'
const sensitivityNarrowed =
!!s && [s.rate_visibility, s.margin_visibility, s.contact_visibility].includes('team')
const teamsMatter = scopeNarrowed || visibilityNarrowed || sensitivityNarrowed
if (teams.isError) {
return (
<Panel title="Teams" hint="who runs which desk">
<Note tone="risk">The teams could not be read. {(teams.error as Error)?.message}</Note>
</Panel>
)
}
return (
<Panel title="Teams" hint={active.length === 1 ? '1 desk' : `${active.length} desks`}>
{s && !teamsMatter && (
<div className="mb-3">
<Note tone="muted">
Nothing is currently narrowed to a team, so these are an org chart rather than an access control.
They start deciding what people see the moment record scope, work visibility or a sensitivity is
set to a team in settings.
</Note>
</div>
)}
{scopeNarrowed && (
<div className="mb-3">
<Note tone="warn">
Record scope is narrowed to a team, so somebody on no team sees only their own records. Every
person who works a desk needs to be on one.
</Note>
</div>
)}
{active.length === 0 ? (
<Empty
title="No desks yet"
reason="A team is a desk: the people who work a shared book of business and can see each other's work on it."
/>
) : (
<ul className="space-y-3">
{active.map((t) => (
<TeamRow key={t.id} team={t} people={people} mayWrite={mayWrite} />
))}
</ul>
)}
{mayWrite && (
<form
className="mt-4 flex flex-wrap gap-2 border-t border-line/40 pt-3"
onSubmit={(e) => {
e.preventDefault()
const name = newName.trim()
if (!name) return
create.mutate({ name }, { onSuccess: () => setNewName('') })
}}
>
<input
value={newName}
maxLength={120}
placeholder="Name a desk, for example Intel Programs"
onChange={(e) => setNewName(e.target.value)}
className={`${fieldCls} max-w-xs`}
/>
<button type="submit" disabled={!newName.trim() || create.isPending} className={btnPrimary}>
<Plus size={13} /> {create.isPending ? 'Creating' : 'Create a desk'}
</button>
</form>
)}
{create.isError && (
<div className="mt-2">
<Note tone="risk">{(create.error as Error).message}</Note>
</div>
)}
{archivedCount > 0 && (
<p className="pt-2 text-[11px] text-faint">
{archivedCount === 1 ? '1 archived desk is' : `${archivedCount} archived desks are`} hidden. An
archived desk grants nothing.
</p>
)}
</Panel>
)
}
function TeamRow({
team,
people,
mayWrite,
}: {
team: Team
people: { id: string; full_name: string; role: string; status: string }[]
mayWrite: boolean
}) {
const add = useAddTeamMember()
const remove = useRemoveTeamMember()
const update = useUpdateTeam()
const [confirmArchive, setConfirmArchive] = useState(false)
const memberIDs = new Set(team.members.map((m) => m.user_id))
// Only ACTIVE accounts can be added. A deactivated account cannot sign in, so putting one on a desk would
// be a grant to nobody and would make the desk look larger than it is.
const addable = people.filter((p) => !memberIDs.has(p.id) && p.status === 'active')
// A lead has to be ON the desk. Offering somebody who is not would create a lead whose own scope does not
// include the team they lead.
const leadChoices = [
{ value: '', label: 'No lead' },
...team.members.map((m) => ({ value: m.user_id, label: m.name })),
]
return (
<li className="rounded-lg border border-line/60 bg-navy-950/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-ink">
<Users2 size={14} className="text-muted" />
{team.name}
<span className="text-[11px] font-normal text-faint">
{team.members.length === 1 ? '1 person' : `${team.members.length} people`}
</span>
</div>
{mayWrite && (
<div className="flex flex-wrap items-center gap-2">
<Select
value={team.lead_user_id ?? ''}
choices={leadChoices}
disabled={team.members.length === 0 || update.isPending}
onChange={(v) => update.mutate({ id: team.id, patch: { lead_user_id: v === '' ? null : v } })}
className="text-xs"
/>
{confirmArchive ? (
<>
<button
type="button"
className={btnGhost}
onClick={() => update.mutate({ id: team.id, patch: { status: 'archived' } })}
>
Archive it
</button>
<button type="button" className={btnGhost} onClick={() => setConfirmArchive(false)}>
Keep it
</button>
</>
) : (
<button type="button" className={btnGhost} onClick={() => setConfirmArchive(true)}>
<Archive size={13} /> Archive
</button>
)}
</div>
)}
</div>
{/* Archiving is stated before it happens, because it can silently narrow what the people on it see. */}
{confirmArchive && (
<div className="mt-2">
<Note tone="warn">
Archiving this desk removes the access it grants. If record scope is narrowed to a team, everybody
on it falls back to their own records until they are on another desk.
</Note>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{team.members.length === 0 && <span className="text-[11px] text-faint">Nobody on this desk yet.</span>}
{team.members.map((m) => (
<span
key={m.user_id}
className="flex items-center gap-1.5 rounded-full border border-line/70 py-0.5 pl-2.5 pr-1 text-[11px] text-muted"
title={m.email}
>
{m.is_lead && <Crown size={10} className="text-gold" />}
<span className={m.disabled ? 'line-through opacity-60' : ''}>{m.name}</span>
{mayWrite && (
<button
type="button"
aria-label={`Remove ${m.name} from ${team.name}`}
onClick={() => remove.mutate({ teamId: team.id, userId: m.user_id })}
className="grid size-4 place-items-center rounded-full text-faint hover:text-risk"
>
<UserMinus size={10} />
</button>
)}
</span>
))}
</div>
{mayWrite && addable.length > 0 && (
<div className="mt-2">
<Select
value=""
choices={[
{ value: '', label: 'Add somebody to this desk' },
...addable.map((p) => ({ value: p.id, label: `${p.full_name} (${p.role})` })),
]}
onChange={(v) => {
if (v) add.mutate({ teamId: team.id, userId: v })
}}
className="text-xs"
/>
</div>
)}
{(add.isError || remove.isError || update.isError) && (
<div className="mt-2">
<Note tone="risk">
{((add.error ?? remove.error ?? update.error) as Error)?.message ?? 'That change did not apply.'}
</Note>
</div>
)}
</li>
)
}