Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de
sections. Fees and receipts (`docs/people/fees.md`): `people:fees:write` reviews, marks paid and reminds;
`people:receipts:sign` downloads and uploads signed receipts for the officer on the caller's record.
Preparing CIE requests additionally requires `esign:sign` and reuses the existing eSign UI and agent; the backend
owns the durable receipt/request association and completion (the People backend contract is still pending). Handoff notes,
decisions and the plan: `docs/people/`.
owns the durable receipt/request association and completion (the People backend contract is still pending). The
backend fills the register from PoliNetwork Auth roles bound to People and the Microsoft 365 Soci group, so a
record's course and year may be empty and its campus `null`. Handoff notes, decisions and the plan: `docs/people/`.
- **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`.

## Conventions
Expand Down
8 changes: 4 additions & 4 deletions docs/people/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ type Profile = {
phone: string | null // omitted (null) without contact rights
telegramUsername: string | null
telegramId: string | null
course: string
year: string // "2nd year", "1st year MSc", "Graduated"
campus: Campus
course: string // "" until known
year: string // "2nd year", "1st year MSc", "Graduated"; "" until known
campus: Campus | null // null until known: records created from PoliNetwork Auth or Microsoft 365
otherAssociations: string[]
studentRepresentative: string | null
}
Expand Down Expand Up @@ -256,7 +256,7 @@ type MyArea =
name: string
course: string
year: string
campus: Campus
campus: Campus | null
roles: string[]
}
reason: string
Expand Down
23 changes: 22 additions & 1 deletion docs/people/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@ Known prototype defects to not reproduce:
- People with no active role stay in Overview as muted rows; the Role filter has "No roles". Their record shows a
neutral alert: the record stays for history and future census campaigns.

## Where the register's people come from

The backend keeps the register and fills it from two sources besides the dashboard and census imports (backend
README, "People register sources"):

- **PoliNetwork Auth roles.** Master Admin binds hand-made roles to People on the role's page in the IdP: `admin`,
`team:<id>:member` or `team:<id>:lead` (team keys `it`, `hr`, `design`, `intl`, `events`). Their holders get a linked
record with the matching Admin and team entries. PoliNetwork Auth is the source of truth: Add to team, End
membership, End admin role, census answers and account links on a linked record send it one command for the
signed-in user, and the IdP applies all of it or nothing, refusing what that user couldn't do in its own dashboard
(no self-grants, Leads need `people:appoint`, nobody hands out permissions they don't hold). A role takes effect in
People only once the IdP confirms it: until then the record keeps its previous roles and other role edits of that
person wait. Linking a record ends the roles recorded before it until the IdP confirms them; a refusal outside a
direct dashboard action flags the record for HR. Records without an account, and roles with no bound role, stay
owned by People.
- **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Each member gets a membership numbered by the
account's `employeeId`. Leaving the group ends nothing; the register keeps the status.

Records created this way have only a name: course, year and campus show "—" until HR edits the profile, and My area
asks the person to choose them before answering the census.

## Per page

- **Overview**: personal email is the secondary line. Admin is a state badge (Active / Former); Capo Admin a scope chip.
Expand All @@ -86,7 +107,7 @@ Known prototype defects to not reproduce:
Year, Campus, Active since, then Requested, Representative, Other associations and Available as Capo Admin appear as
the table widens. Active / Former is the scope control. Row icon: Open in Telegram; the role actions follow it.
- **Teams**: index = one `NavCard` per team (lead, member count, missing census answers) then all team members in one
table with a Team column. Team page columns follow the team's fields (IT: GitHub, Areas; Design & Social: Tools,
table with a Team column. Team page columns follow the team's fields (IT: GitHub, Areas; Design & Marketing: Tools,
Portfolio; HR and Events: Focus; International: Languages). Team Leads see phone and Telegram for their team. Lead
option in Add to team only for `people:appoint`. End membership needs a reason and keeps history.
- **Census**: campaign list is a table with a stacked progress meter and counts. Campaign page: Import responses
Expand Down
4 changes: 4 additions & 0 deletions docs/people/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,7 @@ Newest last. One line per commit or session: date, what was done, what is next.
`people.receipts.prepareSignatures` contract and adapter, both signing permissions, and regression tests for
officer restrictions, loader refresh and merged navigation. The People backend bridge remains pending, so agent
mode reports that requirement instead of simulating CIE success.

- 2026-10-25: the register now fills from PoliNetwork Auth's bound roles and the Microsoft 365 Soci group (backend
`feat/people-role-sync`, IdP `feat/people-role-api`). `profile.campus` is nullable and course/year may be empty;
pages show "—", the profile form starts empty and My area asks for them. The Design team is Design & Marketing.
7 changes: 4 additions & 3 deletions src/features/people/admins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,16 @@ function tableColumns(year: string): DataTableColumn<Admin>[] {
width: 112,
priority: 4,
mobile: "hidden",
cell: (person) => <span className="whitespace-nowrap">{person.profile.year}</span>,
cell: (person) =>
person.profile.year ? <span className="whitespace-nowrap">{person.profile.year}</span> : <Unset />,
},
{
id: "campus",
label: "Campus",
width: 112,
priority: 5,
mobile: "hidden",
cell: (person) => person.profile.campus,
cell: (person) => person.profile.campus ?? <Unset />,
},
{
id: "since",
Expand Down Expand Up @@ -280,7 +281,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) {
const filtered = inScope.filter(
(person) =>
(campus === null || person.profile.campus === campus) &&
(year === null || yearOf(person.profile.year) === year) &&
(year === null || (person.profile.year !== "" && yearOf(person.profile.year) === year)) &&
[...quick].every((id) => QUICK[id].test(person)) &&
matchesQuery(person, deferredQuery)
)
Expand Down
3 changes: 2 additions & 1 deletion src/features/people/capo-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export function CapoPage({ area }: { area: CapoArea }) {
width: 112,
priority: 3,
mobile: "hidden",
cell: (person) => <span className="whitespace-nowrap">{person.profile.year}</span>,
cell: (person) =>
person.profile.year ? <span className="whitespace-nowrap">{person.profile.year}</span> : <Unset />,
},
{
id: "since",
Expand Down
79 changes: 55 additions & 24 deletions src/features/people/my-area-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
Users,
UsersRound,
} from "lucide-react"
import { useState } from "react"
import { Fragment, useState } from "react"

import {
buttonMotion,
Expand Down Expand Up @@ -136,8 +136,14 @@ function MatchView({ area }: { area: Match }) {
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-[15px] font-medium">{candidate.name}</span>
<span className="text-[13px] text-(--pn-fg-muted)">
<span className="font-mono">{candidate.id}</span> · {candidate.course} · {candidate.year} ·{" "}
{candidate.campus}
{[candidate.id, candidate.course, candidate.year, candidate.campus]
.filter(Boolean)
.map((part, index) => (
<Fragment key={part}>
{index ? " · " : ""}
{index ? part : <span className="font-mono">{part}</span>}
</Fragment>
))}
</span>
{candidate.roles.length ? (
<span className="mt-1.5 flex flex-wrap gap-1.5">
Expand Down Expand Up @@ -253,17 +259,19 @@ function CensusCard({ area }: { area: Linked }) {
const adminGroups = isActiveAdmin(person) ? (person.admin?.groups ?? []) : []
const groups = adminGroups.map((group) => group.chatId)
const teams = activeTeams(person).map((membership) => membership.team)
const [course, setCourse] = useState(person.profile.course)
const [year, setYear] = useState<Year>(nextYear(person.profile.year))
const [campus, setCampus] = useState<Campus>(person.profile.campus)
// Records created from PoliNetwork Auth or Microsoft 365 have no course or campus until someone fills them in.
const [course, setCourse] = useState<string | null>(person.profile.course || null)
const [year, setYear] = useState<Year | null>(nextYear(person.profile.year))
const [campus, setCampus] = useState<Campus | null>(person.profile.campus)
const [showMissing, setShowMissing] = useState(false)
const [keepGroups, setKeepGroups] = useState(groups)
const [keepTeams, setKeepTeams] = useState<TeamId[]>(teams)
const [saving, setSaving] = useState(false)
const [leavingOpen, setLeavingOpen] = useState(false)
const leavingKey = useOpenGeneration(leavingOpen)

if (!campaign) return null
const answer = { course, year, campus, keepGroups, keepTeams }
const answer = course && year && campus ? { course, year, campus, keepGroups, keepTeams } : null

// Team roles update immediately; Telegram removals wait for confirmation.
if (entry?.answer) {
Expand Down Expand Up @@ -293,6 +301,10 @@ function CensusCard({ area }: { area: Linked }) {
}

async function confirmContinuing() {
if (!answer) {
setShowMissing(true)
return
}
setSaving(true)
try {
await save({ data: { ...answer, answer: "continues", reason: "", note: "" } })
Expand All @@ -306,9 +318,10 @@ function CensusCard({ area }: { area: Linked }) {
setSaving(false)
}

const courses = COURSES.some((option) => option === person.profile.course)
? COURSES
: [person.profile.course, ...COURSES]
const courses =
!person.profile.course || COURSES.some((option) => option === person.profile.course)
? COURSES
: [person.profile.course, ...COURSES]

return (
<SectionCard
Expand All @@ -322,7 +335,8 @@ function CensusCard({ area }: { area: Linked }) {
className={buttonMotion}
disabled={saving}
onClick={() => {
setLeavingOpen(true)
if (answer) setLeavingOpen(true)
else setShowMissing(true)
}}
>
I'm leaving
Expand All @@ -336,32 +350,47 @@ function CensusCard({ area }: { area: Linked }) {
<div className="flex flex-col gap-4">
<p className="text-[14px] text-(--pn-fg-muted)">Check your details and confirm whether you're continuing.</p>
<div className="grid grid-cols-1 gap-4 shell:grid-cols-3">
<FormField label="Course" htmlFor="census-course">
<FormField
label="Course"
htmlFor="census-course"
error={showMissing && !course ? "Choose your course." : undefined}
>
<SelectField
id="census-course"
value={course}
placeholder="Choose your course"
options={courses.map((option) => ({ value: option, label: option }))}
onChange={setCourse}
invalid={showMissing && !course}
/>
</FormField>
<FormField
label={`Year in ${campaign.year}`}
htmlFor="census-year"
hint={`You were in ${person.profile.year} last year.`}
error={showMissing && !year ? "Choose your year." : undefined}
hint={person.profile.year ? `You were in ${person.profile.year} last year.` : undefined}
>
<SelectField<Year>
id="census-year"
value={year}
placeholder="Choose your year"
invalid={showMissing && !year}
options={YEARS.map((option) => ({ value: option, label: option }))}
onChange={setYear}
/>
</FormField>
<FormField label="Campus" htmlFor="census-campus">
<FormField
label="Campus"
htmlFor="census-campus"
error={showMissing && !campus ? "Choose your campus." : undefined}
>
<SelectField<Campus>
id="census-campus"
value={campus}
placeholder="Choose your campus"
options={CAMPUSES.map((option) => ({ value: option, label: option }))}
onChange={setCampus}
invalid={showMissing && !campus}
/>
</FormField>
</div>
Expand Down Expand Up @@ -394,13 +423,15 @@ function CensusCard({ area }: { area: Linked }) {
</FormField>
) : null}
</div>
<LeavingDialog
key={`leaving-${leavingKey}`}
open={leavingOpen}
onOpenChange={setLeavingOpen}
year={campaign.year}
answer={answer}
/>
{answer && (
<LeavingDialog
key={`leaving-${leavingKey}`}
open={leavingOpen}
onOpenChange={setLeavingOpen}
year={campaign.year}
answer={answer}
/>
)}
</SectionCard>
)
}
Expand Down Expand Up @@ -430,9 +461,9 @@ function LinkedView({ area }: { area: Linked }) {
</span>
) : null,
},
{ key: "Course", value: profile.course },
{ key: "Year", value: profile.year },
{ key: "Campus", value: <Chip>{profile.campus}</Chip> },
{ key: "Course", value: profile.course || null },
{ key: "Year", value: profile.year || null },
{ key: "Campus", value: profile.campus ? <Chip>{profile.campus}</Chip> : null },
...(member || person.account.status === "linked"
? [
{
Expand Down
3 changes: 2 additions & 1 deletion src/features/people/people-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const NEXT_YEAR = {

/** Last year's answer advanced by one, to pre-fill this year's census. */
export function nextYear(value: string) {
return NEXT_YEAR[yearOf(value)]
const known = YEARS.find((year) => year === value)
return known ? NEXT_YEAR[known] : null
}

export function teamName(team: TeamId) {
Expand Down
8 changes: 5 additions & 3 deletions src/features/people/people.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ export const profileSchema = z.object({
phone: z.string().nullable(),
telegramUsername: z.string().nullable(),
telegramId: z.string().nullable(),
/** Empty, like `year`, for people known only from PoliNetwork Auth or Microsoft 365 until HR completes them. */
course: z.string(),
year: z.string(),
campus: campusSchema,
/** `null` until known: people known only from PoliNetwork Auth or Microsoft 365. */
campus: campusSchema.nullable(),
otherAssociations: z.array(z.string()),
studentRepresentative: z.string().nullable(),
})
Expand Down Expand Up @@ -448,7 +450,7 @@ export const myAreaSchema = z.discriminatedUnion("state", [
name: z.string(),
course: z.string(),
year: z.string(),
campus: campusSchema,
campus: campusSchema.nullable(),
roles: z.array(z.string()),
}),
reason: z.string(),
Expand Down Expand Up @@ -524,7 +526,7 @@ export const TEAMS = {
],
},
design: {
name: "Design & Social",
name: "Design & Marketing",
fields: [
{
key: "tools",
Expand Down
2 changes: 1 addition & 1 deletion src/features/people/people.fake.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi {
? [{ question: "Why are you leaving?", answer: person.admin?.endReason ?? "Graduating" }]
: []),
{ question: "Course and year", answer: `${person.profile.course}, ${person.profile.year}` },
{ question: "Campus", answer: person.profile.campus },
{ question: "Campus", answer: person.profile.campus ?? "—" },
{
question: "Telegram username",
answer: `@${person.censusTelegram ?? person.profile.telegramUsername ?? "—"}`,
Expand Down
9 changes: 8 additions & 1 deletion src/features/people/people.fixtures.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ export function buildPeopleStore(): PeopleStore {
account: { name: "Aurora Fabbri", email: "aurora.fabbri@gmail.com", telegram: "@aurorafab", via: "Telegram" },
record: null,
other: null,
message: "I joined Design & Social at the open day.",
message: "I joined Design & Marketing at the open day.",
},
{
id: "r6",
Expand Down Expand Up @@ -1193,6 +1193,13 @@ export function buildPeopleStore(): PeopleStore {
{ id: "j3", personId: (informatics[1] ?? giulia).id, group: "Basi di Dati", requestedOn: "2026-10-12" },
]

// Someone known only from a role in PoliNetwork Auth: the backend links the record but has no course or campus.
const fromAuth = people.find(
(p) => !(p.id in KEYS) && p.account.status === "linked" && p.teams.length && !p.admin && !p.member
)
if (fromAuth)
Object.assign(fromAuth.profile, { course: "", year: "", campus: null, personalEmail: null, phone: null })

return { people, byId, reviews, joinRequests, receipts: seedFees(people, byId) }
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/people/person-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function PersonDialog({
phone: person?.profile.phone ?? "",
telegramUsername: person?.profile.telegramUsername ? `@${person.profile.telegramUsername}` : "",
course: person?.profile.course ?? "",
year: person ? yearOf(person.profile.year) : null,
year: person?.profile.year ? yearOf(person.profile.year) : null,
campus: person?.profile.campus ?? null,
}
const [values, setValues] = useState(initial)
Expand Down
Loading
Loading