diff --git a/AGENTS.md b/AGENTS.md index 841644d9..285fce18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,11 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de the backend's fixed new-member workflow. List groups with `azure:groups:read`; add or remove existing directory users in a group with `azure:groups:write`; the add picker lists the directory, so it is disabled without `azure:members:read`. Users and groups themselves cannot be edited from the dashboard. +- **People** (`src/features/people/`): every page reads and writes through `peopleApi(context)`, which parses the + backend's `people.*` responses with `people.contract.ts` and, in agent mode, uses the in-memory fake + (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). `/dashboard` admits `people:self` without `admin:access`; + Capo Admin and Team Lead come from the person's own record (`peopleRoles` in the route context) and only open their + sections. Handoff notes, decisions and the plan: `docs/people/`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/README.md b/README.md index efd41fdc..2af26db9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ Microsoft 365 supports a read-only member directory, creating a new association workflow, and group membership management. Directory access requires `azure:members:read`; creation independently requires `azure:members:create`. Groups require `azure:groups:read`; adding and removing members `azure:groups:write`. +**People** keeps one record per volunteer and member (Overview, Member register, Admins, Teams, Census, Linking), plus +Telegram › Capo Admin and Account › My area for volunteers themselves. The dashboard talks to a typed `people.*` +contract (`src/features/people/people.contract.ts`, documented in `docs/people/contract.md`); until the backend +implements it, agent mode serves sample data from `people.fake.server.ts`. Permissions: `people:read`, `people:write`, +`people:members:read`, `people:members:write`, `people:appoint`, and `people:self`, which alone admits non-admins to My +area and, by the role on their record, Capo Admin or their team. See `docs/people/README.md`. + Built with TanStack Start (React 19), Vite+, Nitro, Tailwind CSS v4 and shadcn/Base UI. The server is an OIDC BFF: only it holds the IdP access/refresh/ID tokens. Browsers hold an opaque, HttpOnly session cookie; sessions live in a **dedicated Redis**, separate from the shared backend Redis. Backend requests carry a user bearer token, and permissions @@ -34,6 +41,8 @@ Set the following runtime variables (the build does not need secrets): | `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | | `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | | `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | +| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | +| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo). `P-0098` is a Capo Admin, `P-0034` the IT Team Lead, `P-0115` an IT member; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. | | `AGENT_MODE` | Fake all-permission user in development only. Backend must be a local fake or local legacy test instance. Production ignores this mode. | | `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | | `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | diff --git a/docs/design.md b/docs/design.md index 537fa711..0bf6c339 100644 --- a/docs/design.md +++ b/docs/design.md @@ -115,8 +115,9 @@ the `h1`. **Small screens** (below 760px). Rail and panel leave the layout. A 56px top bar holds the logo (Overview), the service title ("PoliNetwork Admin" on Overview), Search, the theme toggle and the avatar; the service's sections follow as horizontally scrolling pills that scroll away with the page. The current pill stays in view after navigation, -font loading and changes to the bar's width, including when it first appears below 760px. A 64px bottom nav lists the five services (current one -accent with a top bar, Reports with the open-reports dot). Section pills and bottom nav are the only navigation; there +font loading and changes to the bar's width, including when it first appears below 760px. A 64px bottom nav lists the visible services (current one +accent with a top bar, Reports with the open-reports dot). The device's bottom safe area adds space below the 64px +bar. Below 360px, WhatsApp uses "WA" so all labels remain readable. Section pills and bottom nav are the only navigation; there is no sheet. **Command palette** (`⌘K`/`Ctrl+K`, the rail and top bar Search, the Overview search): sections, "Find “…” in @@ -331,6 +332,10 @@ keystroke. Errors: danger border, 12px danger text with an icon, `aria-invalid` are text in a `KeyValueList`, never disabled inputs. Length counters (`FieldCounter`) appear only in the last 20% of the limit. +Calendar dates use a custom picker at every viewport, display the same day–month–year format as read-only dates, +and retain ISO values for the server. Month and year selectors are custom controls; calendars start on Monday. +Grant date/time fields use the calendar and time wheels on touch devices too, with no native date/time fallback. + **Alerts and toasts.** `InlineAlert` for what persists or needs action: load errors, dialog submit errors, partial saves. It defaults to `tone="danger"` and `role="alert"`, so pass another tone for notices that aren't errors. Toasts (`appToast.success/info/warning/error`, never raw `toast`) confirm actions; untinted, tone on the icon only, at most @@ -440,3 +445,20 @@ Rules that the templates above don't imply. their own icon. The rail and bottom nav show a dot on Reports while any report is open. - **Account**: permissions are grouped by area (Dashboard, Telegram, WhatsApp, group labels, Microsoft 365, Web) with the service icon. +- **People**: one record per person whether or not they have an account; Overview, Member register, Admins, Teams, + Census and Linking all open the same record (`/dashboard/people/$personId`). Account status always shows a badge, + Not linked included. Mobile list cards replace unlabelled column values with one line of role chips or status + badges in the name cell. Dialogs that need fields and are destructive (End admin role, End membership, I'm leaving) + use `FormDialog` with `submitTone="danger"`; plain confirmations stay `ConfirmDialog`. +- **Member register**: status segments scope the table; Number leads on desktop, the name on mobile. Add member from a + record opens the register's dialog via `?add=P-…`. Export confirms before downloading. +- **Admins**: Groups, Capo Admin of reference, Member and Census never hide; the rest follow the table width. The four + quick filters scroll sideways on phones. +- **Teams**: index cards then all members; a Team Lead without `people:read` sees only their team, as the section + itself. Team-field columns come from `TEAMS` in `people.contract.ts`. +- **Census**: answer segments become a filter menu on phones. The import wizard skips steps with nothing to settle and + saves nothing before Confirm. Reminders report sent and skipped counts after sending. +- **Linking**: Needs review is a queue (its count sits next to the section like Reports › Open); each review kind has + its own resolutions and a required reason. +- **Capo Admin** (Telegram) and **My area** (Account): self-service pages that open with `people:self` and a role on + the person's own record; no personal emails of others, only phone, Telegram and PoliNetwork addresses. diff --git a/docs/people/README.md b/docs/people/README.md new file mode 100644 index 00000000..a318f4af --- /dev/null +++ b/docs/people/README.md @@ -0,0 +1,29 @@ +# People area: handoff + +Everything an agent needs to build, or continue building, the People area of the dashboard. + +| File | What it is | +| ------------------------------ | ---------------------------------------------------------------------------------------- | +| [`brief.md`](brief.md) | The original brief, verbatim | +| [`decisions.md`](decisions.md) | What was settled after the brief: owner decisions, navigation, per-page rules, roles | +| [`contract.md`](contract.md) | The `people.*` backend contract the dashboard codes against | +| [`plan.md`](plan.md) | Phases, checklist and progress log: **start here to know what is done and what is next** | +| [`prototype/`](prototype/) | The clickable prototype's source (structure and copy reference only) | + +## Continuing the work + +1. Read `CLAUDE.md` and `docs/design.md`, then `decisions.md`, `contract.md` and `plan.md` in that order. +2. Check out branch `claude/eager-mayer-bsom7v` and read the open pull request and its latest commits. +3. Pick the first unchecked item in `plan.md`. Commit small and often; tick the box and add a progress-log line in the + commit that finishes an item. +4. Run `PORT=1xxxx AGENT_MODE=true pnpm dev` to see the pages with sample data; set `AGENT_PERMISSIONS` and + `AGENT_PERSON` (once phase 1 adds them) to preview restricted roles. + +## The prototype has visual errors + +The prototype fixes what each page shows and does, not how it measures. Its spacing, paddings, column widths and some +components are hand-made and wrong in places. Do not copy its CSS or markup: build from the repo's shell and primitives +so spacing and behaviour match the rest of the dashboard, and fix anything that looks off. See "Prototype is a +reference" in `decisions.md` for the known defects. + +Prototype canvas (owner only, all pages in desktop/mobile × light/dark): diff --git a/docs/people/brief.md b/docs/people/brief.md new file mode 100644 index 00000000..7fb8df8c --- /dev/null +++ b/docs/people/brief.md @@ -0,0 +1,181 @@ +# People area: original brief + +This is the brief the prototype was built from, kept verbatim so later work answers to the same requirements. Where it +conflicts with [`decisions.md`](decisions.md), the decisions win: they record what was settled afterwards. + +--- + +Prototype a new "People" area for the PoliNetwork Admin dashboard: a clickable, high-fidelity prototype that fits the +existing dashboard exactly. Desktop 1440px and mobile 390px, light and dark theme. + +## Context + +PoliNetwork is a student association at Politecnico di Milano. It runs hundreds of Telegram/WhatsApp course groups +through ~150 volunteer group admins, plus internal teams (IT, HR, Design & Social, International, Events & +Partnerships) and a smaller set of paying members (soci). + +Today volunteer data lives in a Microsoft Forms spreadsheet ("Modulo Volontari", yearly), and members live only as +Microsoft 365 accounts. This area brings everything into the dashboard: one record per person, and on that record three +independent roles: + +- Member (socio): has a member number and a yearly membership status. +- Admin: manages Telegram groups for courses. +- Team member: belongs to one or more internal teams, possibly as Team Lead. + +A person can have any combination of roles, or none (e.g. a former volunteer). The data collected differs by role: +don't merge admin fields and team fields into one table. + +Key concept: a person record exists even if the person never signs in. Many volunteers will never create a PoliNetwork +account. When someone does sign in later, their account gets linked to their existing record (never a duplicate). Every +record therefore shows an account status: Not linked · Invite sent · Linked · Needs review. + +## Existing design system (follow strictly) + +- Shell: 64px left rail with service icons (Overview logo, Search, then Telegram, WhatsApp, Microsoft 365, Web, Reports; + theme toggle and avatar at the bottom), a 240px section panel for services with 2+ sections, then a scrolling content + column with max width 1280px. Below 760px: 56px top bar, horizontally scrolling section pills, 64px bottom nav; tables + become stacked cards; dialogs become bottom sheets. +- Add "People" as a new rail service (monochrome lucide icon such as UsersRound) with its own section panel. +- Page header: one h1 (24px/600) with one muted context line (usually the total, e.g. "148 people"), and at most one + primary action plus one outline action. Then a toolbar row: scope controls → search ("/" shortcut) → filters → count + shown only while filtered ("12 of 148 people"). +- Tables: 12px radius surface, 1px borders, sentence-case 12px headers (no uppercase), 52px rows, 14px cells, first + column with a 32px initials avatar and an optional 12px secondary line. Columns of variable length get fixed widths, + and on narrower widths low-priority columns hide first. A visible "Actions" column holds monochrome 36px icon buttons + with the overflow "⋮" menu far right. Pagination: "Showing 1–20 of 148", 20/50/100 per page. Clickable rows hover + with the soft accent fill. +- Record pages (deep pages): back button + breadcrumb ("People / Giulia Rossi"), then a record header with a 64px + avatar, a 28px name, status badge and meta line, then section cards. +- Tokens: canvas #f6f7f9, surfaces #fff, panel #fbfcfd, one accent cobalt #1156ae (active location, primary action, + links, focus only), neutral grays otherwise. Borders separate, shadows only on floating layers (menus, dialogs, + toasts). Radii: 8 buttons, 12 cards and tables, 14 dialogs, full for badges. Spacing on a 4px grid, 16px card padding. +- Type: DM Sans; DM Mono for identifiers (member numbers, Telegram IDs, emails of Microsoft 365 accounts, academic years + like 2026/27). Weights 400/500/600. No uppercase, no italic, tabular numbers. +- Badges: status badges (dot + label) only for states, tones neutral/brand/success/warning/danger. Chips for categorical + values (team names, roles, campus). Show nothing when a state is absent, and "—" for unset values. +- Dialogs: 18px title + one-sentence description; footer right-aligned with "Cancel" (outline) then the primary action. + Confirmations are titled "{Verb} {object}?" and state the consequence. Forms have the label above the field, + "(optional)" on optional fields, errors on submit. +- Copy: English, sentence case, verb + object buttons ("Import responses", "Send invite"), no "please", "successfully" + or exclamation marks. Toasts: "Invite sent.", "Couldn't link account." Dates: "6 Oct 2026". Write "Microsoft 365", + never "Azure". +- Motion: only for changes the user caused, short and ease-out. No animation on navigation, sorting or filtering. + +## Navigation of the new area + +People (service) + +- Overview: all people +- Member register +- Admins +- Teams (→ one page per team) +- Census +- Linking + +Plus, outside the People service: + +- "Capo Admin": a section visible only to Capo Admins +- "My area": the personal page of any signed-in person with a linked record (it can live with the existing Account + page; propose the best placement) + +## Pages to prototype + +1. People › Overview: one row per person. Columns: name (secondary line: personal email), Member (member number chip or + —), Admin (Active / Former), Teams (chips with "+n" overflow), Capo Admin (scope chip), Account (status badge), Last + census (e.g. "2026/27 · Continues"). Filters: role (Member / Admin / Team member / none), team, campus, account + status, census status. Primary action: "Add person". + +2. Person record (deep page): header with name, account status badge and meta ("P-0142 · Ingegneria Informatica · 2nd + year · Leonardo"). Section cards: + - Profile: personal email, phone, Telegram username, course, year, campus, other associations, student + representative (where). + - Member: number, status, admitted on, current year fee status. Link to the Microsoft 365 account. + - Admin: active since, groups they administer (detected by the bot, with Telegram glyphs), groups they asked to join, + available as Capo Admin, Capo Admin of reference. + - Teams: one row per team with role (Member/Lead), since, and the team-specific fields. + - Census history: one row per academic year with answer and source ("Imported from Forms" / "Dashboard"). + - Consents: volunteer privacy policy (version, date), photo/video (granted/denied, revocable). + - Account: linked or not, and how. Actions: "Send invite", "Link account", "Unlink". + - Activity: who changed what and when. Show discrepancies as warning inline alerts, e.g. "Declared a member in the + 2026/27 census, but not in the member register." + +3. Member register: member number (mono, sortable), name, status (Active / Suspended / Expired / Former), admitted on, + fee for the current year (Due / Receipt uploaded / Paid / Unpaid), Microsoft 365 address, account. Actions: "Add + member", "Change status", "Change member number" (confirm dialog explaining the Microsoft 365 account is updated + too), "Export" (confirm, since it exports personal data). + +4. Admins: name, course, year, campus, active since, groups administered (count + chips), groups requested, student + representative, other associations, available as Capo Admin, Capo Admin of reference, member yes/no, census status. + Quick filters: "No groups", "Available as Capo Admin", "Declared member but not registered", "No census answer". + +5. Teams: + - Index: one card per team (lead avatar, member count, missing census answers), then one table of all team members + with a Team column. + - Team page (e.g. "IT"): active and former members (segmented control), role, since, team-specific fields (IT: GitHub + username, areas such as Frontend/Backend/Bot/Infrastructure; Design & Social: tools, portfolio link), census + status, photo/video consent. The Team Lead can "Add to team", edit team fields and "End membership" (with a + reason). + +6. Census: + - Campaign list (2026/27 open, 2025/26 closed) with progress: expected / answered / continuing / leaving / no answer. + - Campaign page: those four segments as a segmented filter over the list of people. Actions: "Import responses", + "Send reminders" (confirm with count), "Close campaign". + - Import wizard (lg dialog with steps): 1) upload the Forms export (.xlsx/.csv); 2) preview with counts and tabs for + New people, Updates, Possible duplicates, Invalid rows and Discrepancies; 3) resolve duplicates side by side, + choosing "Same person" or "Different people"; 4) confirm Telegram username → Telegram user suggestions + (individually or "Confirm all"); 5) final confirm "Import 148 responses" stating nothing is deleted and "No" + answers end the role with its reason. Re-importing the same file must clearly say "Already imported" rows are + skipped. + +7. Linking: two lists. + - "Needs review": conflicts such as "Telegram account already linked to another record", or signed-in people without + a record who asked to be added. Resolve via dialogs. + - "Pending invites": email, sent on, expires on, with "Resend" and "Revoke". Include the "Link account" dialog: search + IdP accounts, show suggested matches with the reason ("Same verified Telegram account" / "Similar name — check + before linking"), a required reason field, and confirm. + +8. Capo Admin (scoped): header "Ingegneria Informatica · 3 groups". The admins of my groups with name, year, active + since, representative, other associations, phone, and Telegram with quick "Open in Telegram" / "Open in WhatsApp" + buttons. My groups, flagging groups without admins. Requests to join my groups. + +9. My area (self-service), for a linked person: my profile (with "Request a correction"), my roles (member number and + status; admin groups; teams), the open census ("Confirm for 2026/27" or "I'm leaving", with fields pre-filled from + last year), my consents (with revoke), my Capo Admin and my Team Lead, and my team's roster (name, role, Telegram, + PoliNetwork email only). Also show: + - the first-time link flow: "Is this you?" with the matched record summary and "Yes, link my account" / "This isn't + me"; + - the state for a signed-in person with no record: "You're not on the volunteer or member lists", with "Ask to be + added". + +## Role-based views + +Add a prototype-only "Viewing as" switcher (outside the real UI chrome, clearly a prototype control) to preview the same +screens as: + +- Direttivo: everything, the only role that writes the member register and appoints Team Leads and Capo Admins. +- HR: Overview, Admins, Teams, Census, Linking with write access; member register read-only (no write actions shown). +- Capo Admin: only the Capo Admin section and My area. +- Team Lead (IT): the IT team page with full fields and contacts, plus My area. +- Team member (IT): My area and the IT roster (name, role, Telegram, PoliNetwork email; no phone, no notes). +- Admin with no other role: My area only. + +Missing rights remove actions and sections entirely: no disabled buttons and no locked icons. Field visibility: phone and +personal email are visible to Direttivo, HR, Capo Admins (their scope) and Team Leads (their team) only. HR notes and +full census answers are visible to Direttivo and HR only. + +## States to include + +Loading skeletons matching each template, empty states ("No people match" with "Clear filters"; "No campaigns yet"), an +import with errors, a link conflict, an expired invite, and a person with no roles left (archived look). + +## Sample data + +Use realistic Italian names, Politecnico di Milano courses (Ingegneria Informatica, Ingegneria Gestionale, Design della +Comunicazione, Architettura…), years (1st–3rd bachelor, 1st–2nd master, graduated) and campuses (Leonardo, Bovisa, +Cremona, Piacenza, Mantova, Lecco, External). Show ~150 people overall, with only ~40 having a linked account, ~60 +members, ~110 admins and ~25 team members, and some overlaps. + +## Deliverable + +A navigable prototype of the pages above with the "Viewing as" switcher. For each page, add a short note on decisions +you made where this brief was ambiguous. diff --git a/docs/people/contract.md b/docs/people/contract.md new file mode 100644 index 00000000..479c25a2 --- /dev/null +++ b/docs/people/contract.md @@ -0,0 +1,281 @@ +# People area: backend contract + +The dashboard calls these procedures under `people.*` on the tRPC backend (`@polinetwork/backend`). They do not exist +yet: this file is the spec for the backend project. **`src/features/people/people.contract.ts` is the source of +truth** for every shape and input (zod schemas); where this file differs, the code wins. Notably, list procedures +return full rows (`PersonRow`: profile, member, admin, teams, census, consents, account) rather than the summary below, +and `people.me.roles` returns `{ capoScope, leads }` for the navigation. In agent mode the +dashboard serves the same shapes from `src/features/people/people.fixtures.server.ts`. + +Every procedure checks permissions server-side; the "Needs" column is the minimum. Dates are ISO `YYYY-MM-DD` strings +(`Europe/Rome`); timestamps ISO 8601. Academic years are strings like `"2026/27"`. + +## Shared types + +```ts +type PersonId = string // "P-0142" +type AccountStatus = "none" | "invite" | "linked" | "review" +type MemberStatus = "active" | "suspended" | "expired" | "former" +type FeeStatus = "due" | "receipt" | "paid" | "unpaid" +type CensusAnswer = "continues" | "leaving" +type CensusSource = "forms" | "dashboard" +type TeamId = "it" | "hr" | "design" | "intl" | "events" +type TeamRole = "member" | "lead" +type Campus = "Leonardo" | "Bovisa" | "Cremona" | "Piacenza" | "Mantova" | "Lecco" | "External" + +type Profile = { + firstName: string + lastName: string + personalEmail: string | null // omitted (null) without contact rights + 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 + otherAssociations: string[] + studentRepresentative: string | null +} + +type Membership = { + number: string // "0412" + status: MemberStatus + admittedOn: string + fee: FeeStatus | null // current academic year; null for former members + m365Email: string +} + +type AdminRole = { + status: "active" | "former" + since: string + endedOn: string | null + endReason: string | null + groups: { chatId: string; title: string }[] // detected by the bot + requested: { chatId: string; title: string; requestedOn: string }[] + availableAsCapo: boolean + capoOfReference: { id: PersonId; name: string } | null + capoScope: string | null // set when this person is a Capo Admin, e.g. "Ingegneria Informatica" +} + +type TeamMembership = { + team: TeamId + role: TeamRole + since: string + endedOn: string | null + endReason: string | null + fields: Record // per team, see TEAM_FIELDS +} + +type CensusEntry = { + year: string + answer: CensusAnswer | null // null = expected, no answer + answeredOn: string | null + source: CensusSource | null + declaredMember: boolean | null + reminders: number +} + +type Account = + | { status: "none" } + | { status: "invite"; email: string; sentOn: string; expiresOn: string; sentBy: string } + | { status: "linked"; email: string; via: string; linkedOn: string; how: string } + | { status: "review"; reviewId: string; issue: string } + +type PersonSummary = { + id: PersonId + name: string + personalEmail: string | null + course: string + year: string + campus: Campus + telegramUsername: string | null + member: Pick | null // see Backend rules + isMember: boolean // current member; always present + admin: + | (Pick< + AdminRole, + "status" | "since" | "groups" | "requested" | "availableAsCapo" | "capoOfReference" | "capoScope" + > & { + studentRepresentative: string | null + otherAssociations: string[] + }) + | null + teams: Pick[] + account: AccountStatus + lastCensus: { year: string; answer: CensusAnswer } | null + census2627: CensusEntry | null // current campaign entry + photoConsent: "granted" | "denied" | null +} + +type PersonRecord = { + id: PersonId + profile: Profile + member: Membership | null // see Backend rules + isMember: boolean // current member; always present + admin: AdminRole | null + teams: TeamMembership[] + census: CensusEntry[] + consents: { + privacy: { version: string; acceptedOn: string } | null + photo: { status: "granted" | "denied" | "revoked"; on: string } | null + } + account: Account + notes: string | null // null without people:read + discrepancies: { + kind: "declared-member" | "telegram-mismatch" | "needs-review" + message: string + suggestedTelegram?: string + }[] + activity: { actor: string; text: string; at: string }[] +} +``` + +Team fields (`TEAM_FIELDS`): `it` → `github` (text), `areas` (Frontend, Backend, Bot, Infrastructure); `design` → +`tools` (Figma, Illustrator, Photoshop, After Effects, InDesign, Premiere), `portfolio` (URL); `hr` and `events` → +`focus` (multi); `intl` → `languages` (multi). + +## Procedures + +| Procedure | Kind | Input | Output | Needs | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `people.persons.list` | query | — | `PersonSummary[]` | `people:read` | +| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | +| `people.persons.create` | mutation | `Profile` fields + `{ roles: ("admin")[] }` | `{ id }` | `people:write` | +| `people.persons.update` | mutation | `{ id } & Partial` | `void` | `people:write` | +| `people.persons.setNote` | mutation | `{ id, note: string \| null }` | `void` | `people:write` | +| `people.members.create` | mutation | `{ personId, number, admittedOn, fee }` | `{ m365Email }` | `people:members:write` | +| `people.members.setStatus` | mutation | `{ personId, status, reason? }` | `void` | `people:members:write` | +| `people.members.setNumber` | mutation | `{ personId, number }` | `void` (also updates the M365 member ID) | `people:members:write` | +| `people.members.export` | mutation | — | `{ filename, csv }` | `people:members:write` | +| `people.admins.end` | mutation | `{ personId, reason }` | `void` | `people:write` | +| `people.admins.setCapoScope` | mutation | `{ personId, scope: string \| null }` | `void` | `people:appoint` | +| `people.admins.setCapoOfReference` | mutation | `{ personId, capoId: PersonId \| null }` | `void` | `people:write` | +| `people.teams.list` | query | — | `{ id, name, lead, members: (PersonSummary & { membership: TeamMembership; phone: string \| null })[] }[]` | `people:read`, or Lead of that team (only their team) | +| `people.teams.candidates` | query | `{ teamId }` | `{ id, name, course }[]`: people not active in the team, for Add to team | `people:read`, or Lead of that team | +| `people.teams.addMember` | mutation | `{ teamId, personId, role, since, fields }` | `void` | `people:write` or Lead (role `lead` needs `people:appoint`) | +| `people.teams.updateFields` | mutation | `{ teamId, personId, fields }` | `void` | `people:write` or Lead | +| `people.teams.endMembership` | mutation | `{ teamId, personId, lastDay, reason, note? }` | `void` | `people:write` or Lead | +| `people.census.campaigns` | query | — | `{ id, year, status: "open" \| "closed", openedOn, closesOn, expected, continuing, leaving, noAnswer }[]` | `people:read` | +| `people.census.campaign` | query | `{ id }` | campaign + `PersonSummary[]` with that year's `CensusEntry` | `people:read` | +| `people.census.answers` | query | `{ personId, year }` | `{ question, answer }[]` | `people:read` | +| `people.census.remind` | mutation | `{ campaignId, personId? }` | `{ sent, skipped }` (skips reminded < 3 days) | `people:write` | +| `people.census.close` | mutation | `{ campaignId }` | `void` | `people:write` | +| `people.census.importPreview` | mutation | `FormData` (`campaignId`, `file` .xlsx/.csv) | `ImportPreview` | `people:write` | +| `people.census.importCommit` | mutation | `{ importId, duplicates: Record, telegram: Record }` | `{ imported }` | `people:write` | +| `people.linking.reviews` | query | — | `Review[]` | `people:read` | +| `people.linking.resolve` | mutation | `{ reviewId, resolution, reason }` | `void` | `people:write` | +| `people.linking.dismiss` | mutation | `{ reviewId }` | `void` | `people:write` | +| `people.linking.invites` | query | — | `{ personId, name, email, sentOn, expiresOn, sentBy }[]` | `people:read` | +| `people.linking.sendInvite` | mutation | `{ personId }` (sends or resends; the old link stops working) | `void` | `people:write` | +| `people.linking.revokeInvite` | mutation | `{ personId }` | `void` | `people:write` | +| `people.linking.searchAccounts` | query | `{ personId, query }` | `{ id, name, email, telegram, via, linkedTo: { id, name } \| null, reason: "same-telegram" \| "same-email" \| "similar-name" }[]` | `people:write` | +| `people.linking.link` | mutation | `{ personId, accountId, reason }` (fails `CONFLICT` when the account is linked elsewhere) | `void` | `people:write` | +| `people.linking.unlink` | mutation | `{ personId }` | `void` | `people:write` | +| `people.capo.get` | query | — | `{ scope, groups: { chatId, title, admins: number }[], admins: (PersonSummary & { phone: string \| null })[], requests: { id, person, group, requestedOn }[] }` | Capo Admin (scope from record) | +| `people.capo.approve` / `.decline` | mutation | `{ requestId }` | `void` | Capo Admin of that group | +| `people.me.get` | query | — | `MyArea` (below) | `people:self` | +| `people.me.confirmMatch` | mutation | — | `void` | `people:self` | +| `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `people:self` | +| `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | +| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepGroups: string[], keepTeams: TeamId[], reason?, note? }` | `void` | `people:self` | +| `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | +| `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | + +```ts +type Review = { + id: string + kind: "telegram-conflict" | "ask-to-be-added" | "two-records" | "not-me" | "name-only" + since: string + account: { name: string; email: string; telegram: string | null; via: string } + record: { id: PersonId; name: string } | null + other: { id: PersonId; name: string } | null + message: string | null +} + +type ImportPreview = { + importId: string + fileName: string + rows: number + counts: { + newPeople: number + updates: number + duplicates: number + invalid: number + discrepancies: number + alreadyImported: number + leaving: number + } + tabs: Record< + "newPeople" | "updates" | "duplicates" | "invalid" | "discrepancies" | "alreadyImported", + { row: number; name: string; detail: string; answer: CensusAnswer | null }[] + > + duplicates: { + pairId: string + row: number + file: Record + record: { id: PersonId } & Record + }[] + telegram: { + rowId: string + name: string + username: string + suggestion: { telegramId: string; name: string; info: string } | null + }[] +} + +type MyArea = + | { + state: "linked" + person: PersonRecord + campaign: { year: string; closesOn: string; entry: CensusEntry } | null + contacts: { + role: "capo" | "lead" | "hr" + name: string + telegram: string | null + email: string | null + team?: TeamId + }[] + roster: { + team: TeamId + members: { name: string; role: TeamRole; telegram: string | null; email: string | null }[] + } | null + } + | { + state: "match" + candidate: { id: PersonId; name: string; course: string; year: string; campus: Campus; roles: string[] } + reason: string + } + | { state: "none"; request: { sentOn: string } | null } +``` + +## Permissions + +New IdP permissions (add to `src/server/permissions.ts`): `people:self`, `people:read`, `people:write`, +`people:members:read`, `people:members:write`, `people:appoint`. Capo Admin and Team Lead are not permissions: they +come from the signed-in person's record (`people.me.get`), and the backend scopes `people.capo.*` and team writes to +them. + +## Backend rules + +The dashboard checks these too, but the backend owns them (from the security review of PR 97): + +- **`admin:access`**: every `people.*` procedure except `people.me.*`, `people.capo.*` and the team procedures needs it on + top of the permission in the table. `people:self` alone reaches only My area, Capo Admin and the caller's own team. +- **Member details**: `member` (number, status, fee, admission date, Microsoft 365 address) needs + `people:members:read`, except on the caller's own record. Without it `member` is `null` and only `isMember` says + whether the person is a current member, in every procedure that returns people. +- **Unconfirmed match**: while the signed-in account is only a `match` candidate (not confirmed, or rejected), it acts as + a person with no record: no team, no Capo Admin scope, no census answer or correction on the candidate's record. Only + `people.me.get`, `confirmMatch` and `rejectMatch` see the candidate. +- **`people.capo.approve` / `.decline`**: only requests for groups in the caller's own Capo Admin scope; anything else + answers as already handled, so request IDs of other scopes leak nothing. +- **`people.teams.endMembership`**: ending a lead's membership needs `people:appoint`, like appointing one. +- **`people.members.export`**: every CSV cell is quoted, and a cell starting with `=`, `+`, `-`, `@`, tab or carriage + return gets a leading `'`, so spreadsheets don't run it as a formula. +- **`people.census.importPreview`**: check the content, not the extension: a real .xlsx (ZIP) or UTF-8 CSV, at most + 10 MB uploaded, a cap on the decompressed size (e.g. 50 MB) and on the rows (e.g. 5,000), and reject anything else + before parsing. +- **Input limits**: the input schemas in `people.contract.ts` bound every string, array and record (team fields: at most + 10 keys of 40 characters, values of 200, lists of 20; import decisions: at most 5,000). Telegram usernames are empty or + 5–32 letters, digits and underscores, with an optional leading `@`. `requestCorrection.field` is one of + `CORRECTION_FIELDS`. diff --git a/docs/people/decisions.md b/docs/people/decisions.md new file mode 100644 index 00000000..97763b6d --- /dev/null +++ b/docs/people/decisions.md @@ -0,0 +1,124 @@ +# People area: decisions + +Settled decisions on top of [`brief.md`](brief.md). When the brief, the prototype and this file disagree, this file +wins; when this file and [`docs/design.md`](../design.md) disagree on visuals, `docs/design.md` wins. + +## Decisions made with the owner + +1. **Dashboard only.** `@polinetwork/backend` 1.4.0 has no People procedures (only `auth`, `azure`, `groups`, `me`, + `test`). This repo implements the whole UI against the typed contract in [`contract.md`](contract.md). Agent mode + serves in-memory sample data; outside agent mode the server functions call `people.*` on the backend, and the area + stays hidden until the IdP grants the new permissions. The backend work is a separate project that implements the + same contract. +2. **Restricted access.** People without `admin:access` can enter the dashboard when they hold `people:self`. They see + only My area, plus Capo Admin or their team when their record gives them that role. Every other service stays + behind `admin:access`. + +## Prototype is a reference, not a pixel spec + +The prototype (`prototype/`, and the canvas at , private to the +owner) fixes **structure, content, copy and behaviour**: which pages exist, what each shows, column sets, actions, +dialogs, states and role rules. It was hand-built without the real components and **has visual errors**: wrong or +uneven spacing, paddings off the 4px rhythm, hand-tuned column widths, approximate chip overflow, ad-hoc CSS for things +the repo already has primitives for. Never port `prototype.css` or its markup. Build every screen from +`@/components/shell` and `@/components/primitives` (DataTable, PageBar, PageContent, RecordHeader, SectionCard, +KeyValueList, StatusBadge, Chip, ChipOverflow, SegmentedControl, FormDialog, ConfirmDialog, InlineAlert, EmptyState, +skeletons…) so spacing, radii and motion come out right by construction. Where the prototype and a primitive disagree, +the primitive is right. + +Known prototype defects to not reproduce: + +- Card headers use 12px/16px uneven padding; use `SectionCard`. +- Filter buttons render "Role:" and the value as separate flex items; use the repo's filter control pattern. +- Chip overflow is estimated from character counts; use `ChipOverflow`. +- Quick filters on Admins are ad-hoc pills; use the repo's toggle/segmented pattern or filter menu (decide in code, + note it in `docs/design.md` §8). +- Campaign rows reuse a person avatar showing "26"; a campaign row has no avatar. +- Skeletons are hand-drawn; use the template skeletons (`TableSkeleton`, `RecordSkeleton`, `CardsSkeleton`, + `SettingsListSkeleton`). +- Toolbar, table footer and dialog paddings are hand-set; take them from the primitives. +- Some actions only toast ("Appoint as Capo Admin", "Start campaign", "Open in Telegram/WhatsApp"); in code they get real + dialogs or links (Telegram `https://t.me/{username}`, WhatsApp `https://wa.me/{digits}`). +- The "/" search shortcut is drawn but not wired; in code it is the shell's existing `/` behaviour. + +## Navigation + +- **People** is a rail service (`UsersRound`), placed first after Search. Sections: Overview, Member register, Admins, + Teams, Census, Linking. Deep pages: person (`/dashboard/people/$personId`), team (`/dashboard/people/teams/$teamId`), + campaign (`/dashboard/people/census/$campaignId`). +- **Capo Admin** is a Telegram section (`/dashboard/telegram/capo`, icon `Crown`), visible only to Capo Admins. A Capo + Admin without other rights sees Telegram with that one section, so no panel (one-section rule). +- **My area** lives under the avatar: Account becomes a two-section service, My area (`/dashboard/account/me`, default) + and Sign-in & security (today's `/dashboard/account`, unchanged content). +- A **Team Lead** without people rights gets People with only Teams, which opens their team directly. +- People with only `people:self`: the logo links to My area instead of Overview. +- Linking shows its needs-review count in the panel, like Reports › Open, because it is a queue. + +## Data and display + +- Account status always shows a badge, Not linked included (Not linked neutral, Invite sent brand, Linked success, + Needs review warning). Expired invites show a danger "Expired {date}" badge in Linking and on the record. +- Member numbers are 4-digit DM Mono (`0412`); person IDs are `P-0142` in DM Mono. +- Member status tones: Active success, Suspended danger, Expired warning, Former neutral. Fee: Paid success, Receipt + uploaded brand, Due warning, Unpaid danger. +- Census answers: Continues success, Leaving warning, No answer neutral. Source text: "Imported from Forms" / + "Dashboard". +- 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. + +## Per page + +- **Overview**: personal email is the secondary line. Admin is a state badge (Active / Former); Capo Admin a scope chip. + Last census reads "year · answer" and falls back to the last answered year. Row actions: Send invite (only while not + linked; linked rows keep an empty slot so icons align) and ⋮ (Open record, Link account, Add to team). Columns hide + by priority: Capo Admin, then Last census, then Teams. +- **Person record**: two columns from 1100px (1.55 : 1): left Profile, Admin, Teams, Census history; right Account, + Member, Consents, HR notes. Activity full width below. Discrepancies are warning `InlineAlert`s above the cards, each + with the action that fixes it. Account actions follow the state: Not linked → Send invite + Link account; Invite sent + → Resend invite + Link account; Linked → Unlink. Header outline action "Add role". Capo Admins and Team Leads do not + open full records. +- **Member register**: status is the scope control (All / Active / Suspended / Expired / Former), fee a filter. Number + is the first, sortable column; on mobile the name leads the card. Change member number is a confirmation with the + field and the consequence (the Microsoft 365 account's member ID changes too). Export confirms first. HR sees no + Actions column and no header actions. +- **Admins**: course is the name's secondary line. Groups, Capo Admin of reference, Member and Census always show; + 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 rest in ⋮. +- **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, + 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 + (primary), Send reminders (outline), Close campaign in ⋮. Segments: Expected (all), Answered, Continuing, Leaving, No + answer. Reminders skip people reminded in the last 3 days, and the confirmation says how many. No "New campaign" + while one is open. +- **Import wizard**: lg dialog (bottom sheet on mobile), five steps Upload → Review → Duplicates → Telegram → Confirm; + steps without work are skipped. Review: counts then tabs (New people, Updates, Possible duplicates, Invalid rows, + Discrepancies, plus Already imported on a re-import). Invalid rows are skipped with a reason. Duplicates one pair at a + time, side by side, differences highlighted. Confirm states that nothing is deleted and that "No" answers end the role + with its reason. The file is parsed by the backend; the dashboard uploads it and renders the preview it returns. +- **Linking**: segmented scope Needs review (queue, Dismiss ghost / Resolve outline) and Pending invites (table, Resend / + Revoke). Link account dialog: search, suggested matches with reason badge, required reason, inline conflict error that + keeps the dialog open. +- **Capo Admin**: context line names the scope ("Ingegneria Informatica · 3 groups"). Cards My groups (warning badge on + groups without admins) and Requests to join (Decline ghost / Approve outline) above the Admins of my groups table + (phone, Telegram, Open in Telegram / Open in WhatsApp; no personal email). +- **My area**: open census first while unanswered, pre-filled from last year (year advanced by one). My roles, People to + contact (Capo Admin, Team Lead, HR), My profile with Request a correction (no direct edits: HR owns records), My + consents with Revoke on photo/video. Team roster: name, role, Telegram, PoliNetwork email only. First sign-in: "Is + this you?" card with the matched record and the match reason. No record: empty state with Ask to be added, then a + "Waiting for HR" state. + +## Roles to permissions + +| Viewer | Permissions (IdP) | Sees | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Direttivo | `admin:access`, `people:read`, `people:write`, `people:members:read`, `people:members:write`, `people:appoint`, `people:self` | Everything | +| HR | `admin:access`, `people:read`, `people:write`, `people:members:read`, `people:self` | All People sections; member register read-only | +| Capo Admin | `people:self` (+ Capo Admin scope on their record) | Telegram › Capo Admin, My area | +| Team Lead | `people:self` (+ Lead role on their record) | People › Teams (their team, full fields), My area | +| Team member | `people:self` | My area with their team roster | +| Admin only | `people:self` | My area | + +Contacts (phone, personal email) need `people:read`, or Capo Admin scope / Team Lead role over that person. HR notes and +full census answers need `people:read`. The backend enforces all of this again; the dashboard only hides. diff --git a/docs/people/plan.md b/docs/people/plan.md new file mode 100644 index 00000000..82901214 --- /dev/null +++ b/docs/people/plan.md @@ -0,0 +1,171 @@ +# People area: implementation plan + +Work happens on branch `claude/eager-mayer-bsom7v` in one pull request. Tick a box in the same commit that finishes it, +and add a line to the progress log at the bottom. Each phase ends green on `pnpm check`, `pnpm typecheck`, `pnpm test` +and `pnpm build`, and is committed and pushed before the next one starts (Conventional Commits, see `CLAUDE.md`). + +Read first: `CLAUDE.md`, `docs/design.md`, [`decisions.md`](decisions.md), [`contract.md`](contract.md). Look at the +prototype for structure and copy only (see decisions: it has visual errors; build from the primitives). + +## Architecture + +``` +src/features/people/ + people.contract.ts zod schemas + types from contract.md, TEAM_FIELDS, labels and tones + people.fixtures.server.ts seeded in-memory data (port of buildData() in the prototype), mutated by agent-mode calls + people.source.server.ts peopleSource(context): fixtures in agent mode, else context.backend.people (typed via contract) + people.functions.ts read server functions (GET) per page + people-write.functions.ts mutations (POST), each with its exact permission middleware + me.functions.ts My area reads and self-service mutations (people:self) + capo.functions.ts Capo Admin reads and approve/decline + format.ts display helpers (census parts, role names, initials, team field text) + overview-page.tsx, person-page.tsx, members-page.tsx, admins-page.tsx, teams-page.tsx, team-page.tsx, + census-page.tsx, campaign-page.tsx, import-dialog.tsx, linking-page.tsx, link-account-dialog.tsx, + resolve-dialog.tsx, capo-page.tsx, my-area-page.tsx, dialogs/*.tsx +src/routes/dashboard/people/ index (overview), $personId, members, admins, teams/index, teams/$teamId, + census/index, census/$campaignId, linking +src/routes/dashboard/telegram/capo.tsx +src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page moved here) +``` + +- The backend client type has no `people` router, so `people.source.server.ts` declares the contract's router shape and + narrows `context.backend` to it in one place, with a safety comment (lint rule + `anti-slop/require-safety-comment-for-type-assertion`). Nothing else touches the cast. +- Agent mode: `loadAccess` keeps returning every permission. Add a development-only `AGENT_PERMISSIONS` env (comma + list) and `AGENT_PERSON` (person ID for My area) so restricted views can be previewed; document both in README. +- Access: `loadAccess` returns `authorized` when the user has `admin:access` **or** `people:self`. The route context + carries permissions as today; `useCan` and nav filtering hide everything else. `adminMiddleware` stays as is for + existing services; People reads use a new `peopleReadMiddleware` (`people:read`), and self-service functions use + `peopleSelfMiddleware` (`people:self`). Overview (`/dashboard`) and every existing route must still require + `admin:access`: add a guard so a `people:self`-only user is redirected to `/dashboard/account/me`. +- Tests: list every new `*.functions.ts` and each POST function with its consumer file, and every `useCan` file with + its permission, in `tests/server-security.test.mjs` (the failing test names what to add). + +## Phases + +### 0. Handoff and PR + +- [x] `docs/people/` (brief, decisions, contract, plan, prototype), draft PR opened + +### 1. Foundation + +- [x] Permissions: add the six `people:*` permissions; middlewares `peopleReadMiddleware`, `peopleWriteMiddleware`, + `peopleTeamWriteMiddleware`, `peopleMembersReadMiddleware`, `peopleMembersWriteMiddleware`, + `peopleAppointMiddleware`, `peopleSelfMiddleware` (all built on `dashboardUserMiddleware`) +- [x] Access: `people:self` admits to the dashboard; `adminMiddleware` requires `admin:access` explicitly; the + `/dashboard` route redirects pages the user can't open (`canOpenPage`); `AGENT_PERMISSIONS` / `AGENT_PERSON` +- [x] `people.contract.ts` (zod; source of truth for shapes), `people.fixtures.server.ts`, `people.fake.server.ts` + (agent-mode API), `people.api.server.ts` (`PeopleApi`; backend adapter through an untyped tRPC client whose + responses are parsed with the contract) +- [x] Nav: People service and sections, Telegram › Capo Admin, Account as a two-section service (My area, Sign-in & + security); `PageMatch` kinds and `matchPath`; `sectionVisible` filters by permission, `admin:access` and record + role (Capo Admin, Team Lead); the logo and avatar lead to My area for `people:self`-only users +- [x] Route files for every page (placeholder component `PagePlaceholder` until each phase replaces it; add + `pendingComponent` skeletons with the real pages) +- [x] Tests registry updated; all four checks green + +### 2. Overview and person record + +- [ ] Overview: DataTable with columns, filters (role, team, campus, account, census), search, count, pagination, + Send invite + ⋮ row actions, empty states +- [x] Add person / Edit profile dialog (`FormDialog` lg) +- [ ] Person record: RecordHeader, discrepancy alerts, all cards, Activity; Send / resend invite, revoke, unlink + confirmations; HR notes dialog; census answers dialog + +### 3. Member register + +- [x] Table, status segments, fee filter, sort by number/name/admitted +- [ ] Add member, Change status, Change member number, Export (confirm then download) + +### 4. Admins + +- [x] Table with priority columns, Active/Former, campus/year filters, the four quick filters +- [x] End admin role; set Capo Admin of reference; appoint Capo Admin (`people:appoint`) + +### 5. Teams + +- [x] Index cards + all-members table +- [x] Team page (Active/Former, team fields, contacts for Lead); Team Lead lands here from People › Teams +- [x] Add to team, Edit team fields, End membership + +### 6. Census + +- [x] Campaign list; campaign page with segments, reminders, close +- [x] Import wizard: upload, review tabs, duplicates, Telegram suggestions, confirm; re-import "Already imported" + +### 7. Linking + +- [x] Needs review queue with Dismiss / Resolve dialog per kind +- [x] Pending invites with Resend / Revoke and expired badge +- [x] Link account dialog with suggestions, required reason, inline conflict (built in phase 2) + +### 8. Capo Admin + +- [x] My groups, Requests to join (approve/decline), Admins of my groups with Telegram/WhatsApp links + +### 9. My area + +- [x] Linked: census card (confirm / leaving dialog), roles, contacts, profile + Request a correction, consents + + revoke, team roster +- [x] First sign-in match ("Is this you?") and "This isn't me" state +- [x] No record: Ask to be added dialog and waiting state + +### 10. Finish + +- [ ] Loading and empty states checked on every page; mobile (390px) and dark checked on every page in agent mode +- [x] `docs/design.md` §8 page notes for People, Capo Admin, My area; README permissions table; `CLAUDE.md` + architecture lines +- [x] PR description final, all checks green + +## Progress log + +Newest last. One line per commit or session: date, what was done, what is next. + +- 2026-10-14: phase 0 done (handoff docs, prototype copy, draft PR). Next: phase 1. +- 2026-10-14: phase 1 done. Verified in agent mode: Direttivo reaches People; `AGENT_PERMISSIONS=people:self` with + `AGENT_PERSON=P-0098` (Capo Admin) reaches only Telegram › Capo Admin and Account; with `P-0034` (IT Team Lead) + only People › Teams and Account. Next: phase 2 (Overview and person record). +- 2026-10-14: phase 2 done: Overview, person record, Add/Edit person, account confirmations, Link account, HR notes, + census answers. Mobile Overview cards show one line of role chips + account badge instead of unlabelled column + values. The record's Member/Teams/Admin card actions arrive with their phases (3–5). Next: phase 3 (Member register). +- 2026-10-14: phase 3 done: Member register (status segments, fee filter, sort, search), Add member, Change status, + Change member number, Export; the record's Member card menu and the "declared member" alert's Add member, which + opens the register's dialog via `?add=P-…`. The register loads `people.persons.list`, so it assumes + `people:members:read` comes with `people:read` (true for every role in decisions.md). Dialogs now reload before + closing. Next: phase 4 (Admins). +- 2026-10-14: phase 4 done: Admins (Active/Former, campus/year, four quick filters that scroll sideways on phones, + truncating chips), End admin role (reason required, danger submit via the new `FormDialog` `submitTone`), Capo Admin + of reference (`people.admins.setCapoOfReference`, added to the contract and fake), appoint / change / end Capo + Admin (`people-appoint.functions.ts`, `people:appoint`). Same actions on the record's Admin card. Next: phase 5 + (Teams). +- 2026-10-14: phase 5 done: Teams index (NavCards with lead and missing census answers, all-members table), team page + (RecordHeader, Active/Former, team-field columns and filter, contacts for readers and the Lead), Add to team (Lead + role only with `people:appoint`), Edit team fields, End membership; the record's Teams card and header "Add role". + New `people.teams.candidates` query (names only, for Leads without `people:read`) and `peopleTeamReadMiddleware`. + Verified as IT Lead (`AGENT_PERMISSIONS=people:self AGENT_PERSON=P-0034`). Next: phase 6 (Census). +- 2026-10-14: phase 6 done: Census list (stacked progress meter), campaign page (progress card, answer segments that + become a filter menu on phones, role filter, per-row reminder, View answers), Send reminders (the toast reports sent + and skipped: `CensusEntry` has no last-reminded date, so the count can't be known before sending), Close campaign + (now implemented in the fake), Import wizard (`previewCensusImport` takes `FormData`, .xlsx/.csv up to 10 MB; steps + without work are skipped). "Start campaign" is left out: the contract has no procedure for it. Next: phase 7 + (Linking). +- 2026-10-14: phase 7 done: Linking (Needs review / Pending invites scope, Dismiss, Resolve dialog with the account + and record side by side and one set of resolutions per review kind, Resend / Revoke with expired badge). The + Needs review count streams from the `/dashboard` loader as `needsReview` and shows next to People › Linking, like + Reports › Open (`QueueCount` in `panel.tsx`). "Link to an existing record" for ask-to-be-added points HR to the + record's Link account instead: the contract links from the record side. Next: phase 8 (Capo Admin). +- 2026-10-14: phase 8 done: Telegram › Capo Admin (My groups with a No admins badge, Requests to join with Decline / + Approve, Admins of my groups with phone, Telegram and Open in Telegram / WhatsApp links; no personal email). + `people-capo.functions.ts` uses `peopleSelfMiddleware`; the backend scopes it to the Capo Admin role. Verified as + P-0098 with `AGENT_PERMISSIONS=people:self`. Next: phase 9 (My area). +- 2026-10-14: phase 9 done: Account › My area in its three states. Linked: census card pre-filled with the year + advanced (a changed answer starts from the year already given), I'm leaving dialog, My roles, People to contact, + My profile with Request a correction, My consents with Revoke, team roster. First sign-in: "Is this you?" with + This isn't me (the fake now records it as a request, so the page shows Request sent). No record: Ask to be added and + Request sent. Sign-in & security drops its "Account" title for its section title. Row components moved to + `people-ui.tsx`. Next: phase 10 (finish). +- 2026-10-14: phase 10: docs (design.md §8 People notes, README, CLAUDE.md architecture), placeholder removed, every + page swept at 1440 and 390 in agent mode with no console errors or page-level horizontal scroll. Without agent mode + every People route redirects to sign-in. Still open: empty states are written for every list but were never seen + with empty data (the fake has no empty mode), loading skeletons were not looked at, and the signed-in flow without + agent mode needs a development IdP client. diff --git a/docs/people/prototype/README.md b/docs/people/prototype/README.md new file mode 100644 index 00000000..beed3b6b --- /dev/null +++ b/docs/people/prototype/README.md @@ -0,0 +1,14 @@ +# Prototype source + +`prototype.dc.html` and `prototype.css` are the clickable prototype from the Design canvas +(). They run only inside that canvas (they need its `support.js` +runtime), so read them as reference: + +- `buildData()` in the script is the sample-data generator to port to `src/features/people/people.fixtures.server.ts` + (same seed, names, courses, groups, distributions and key people such as Giulia Rossi `P-0142`, Davide Russo + `P-0098`, Sara Galli `P-0034`, Matteo Conti `P-0115`). +- `VIEWS` shows what each role sees; `page*()` methods list each page's columns, filters, actions and copy; `dlgView()` + holds every dialog's copy and validation messages; `importView()` the import wizard. +- `prototype.css` is **not** to be ported: it is a stand-in for the repo's components and contains spacing errors. + +These files are excluded from `pnpm check` (see `vite.config.ts`). diff --git a/docs/people/prototype/prototype.css b/docs/people/prototype/prototype.css new file mode 100644 index 00000000..0bfed934 --- /dev/null +++ b/docs/people/prototype/prototype.css @@ -0,0 +1,545 @@ +/* PoliNetwork Admin — People prototype. Tokens copied from src/styles.css (--pn-*). */ +.pn { + --pn-brand-50: #f2f7fe; --pn-brand-100: #e2eeff; --pn-brand-200: #c5ddff; --pn-brand-300: #95c0fe; + --pn-brand-400: #619cf1; --pn-brand-500: #3578d7; --pn-brand-600: #1156ae; --pn-brand-700: #0a448e; + --pn-brand-800: #0a366f; --pn-brand-900: #0a2953; --pn-brand-950: #071a36; + --pn-bg: #f6f7f9; --pn-surface: #ffffff; --pn-surface-raised: #ffffff; --pn-nav: #ffffff; --pn-panel: #fbfcfd; + --pn-sunken: #f9fafc; --pn-nav-active: #e4e8ed; --pn-muted: #f2f4f7; + --pn-line: oklch(0.22 0.024 250 / 0.1); --pn-line-strong: oklch(0.22 0.024 250 / 0.18); + --pn-fg: #121b25; --pn-fg-muted: #58626c; --pn-fg-subtle: #727c86; + --pn-accent: #1156ae; --pn-accent-hover: #0a448e; --pn-accent-solid: #1156ae; --pn-accent-solid-hover: #0a448e; + --pn-accent-solid-fg: #ffffff; --pn-accent-soft: #f2f7fe; --pn-accent-soft-hover: #e2eeff; + --pn-hover: color-mix(in oklab, #3578d7 6%, #ffffff); + --pn-hover-line: color-mix(in oklab, #1156ae 40%, oklch(0.22 0.024 250 / 0.18)); + --pn-focus: #3578d7; + --pn-success-fg: #00642b; --pn-success-bg: #e8f9eb; --pn-success-solid: #007835; + --pn-warning-fg: #774500; --pn-warning-bg: #fff1e4; --pn-warning-solid: #8e5300; + --pn-danger-fg: #8c2d28; --pn-danger-bg: #ffefed; --pn-danger-solid: #a43b35; + --pn-info-fg: #0a448e; --pn-info-bg: #f2f7fe; + --pn-scrim: oklch(0.16 0.02 250 / 0.4); + --pn-shadow-float: 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 2px 4px oklch(0.22 0.024 250 / 0.04), 0 8px 24px oklch(0.22 0.024 250 / 0.08); + --pn-shadow-modal: 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 4px 8px oklch(0.22 0.024 250 / 0.04), 0 24px 48px oklch(0.22 0.024 250 / 0.12); + --pn-media-ring: inset 0 0 0 1px oklch(0 0 0 / 0.1); + --pn-ease-out: cubic-bezier(0.32, 0.72, 0, 1); + --pn-ease-in: cubic-bezier(0.4, 0, 1, 1); + --sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --mono: "DM Mono", ui-monospace, monospace; + height: 100vh; display: flex; flex-direction: column; overflow: hidden; + font-family: var(--sans); font-size: 14px; line-height: 20px; color: var(--pn-fg); + background: #dfe2e7; -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; +} +.pn[data-theme="dark"] { + --pn-bg: #090f15; --pn-surface: #11181f; --pn-surface-raised: #192028; --pn-nav: #060b11; --pn-panel: #0b1218; + --pn-sunken: #0d141a; --pn-nav-active: oklch(1 0 0 / 0.08); --pn-muted: #1b222a; + --pn-line: #262f38; --pn-line-strong: #353e47; + --pn-fg: #f2f4f7; --pn-fg-muted: #9da6ae; --pn-fg-subtle: #727c86; + --pn-accent: #95c0fe; --pn-accent-hover: #c5ddff; --pn-accent-solid: #619cf1; --pn-accent-solid-hover: #95c0fe; + --pn-accent-solid-fg: #060e16; --pn-accent-soft: #0a2953; --pn-accent-soft-hover: #071a36; + --pn-hover: color-mix(in oklab, #95c0fe 7%, #192028); + --pn-hover-line: color-mix(in oklab, #95c0fe 35%, #353e47); + --pn-focus: #95c0fe; + --pn-success-fg: #83d494; --pn-success-bg: #122d19; --pn-success-solid: #83d494; + --pn-warning-fg: #f3ad66; --pn-warning-bg: #372108; --pn-warning-solid: #f3ad66; + --pn-danger-fg: #ffa196; --pn-danger-bg: #3b1c19; --pn-danger-solid: #c9524a; + --pn-info-fg: #95c0fe; --pn-info-bg: #0a2953; + --pn-scrim: oklch(0 0 0 / 0.6); + --pn-shadow-float: 0 0 0 1px #353e47, 0 8px 24px oklch(0 0 0 / 0.5); + --pn-shadow-modal: 0 0 0 1px #353e47, 0 24px 48px oklch(0 0 0 / 0.6); + --pn-media-ring: inset 0 0 0 1px oklch(1 0 0 / 0.1); + background: #2a2f36; color-scheme: dark; +} +.pn *, .pn *::before, .pn *::after { box-sizing: border-box; } +.pn button, .pn input, .pn select, .pn textarea { font: inherit; color: inherit; } +.pn button { cursor: pointer; background: none; border: 0; padding: 0; margin: 0; } +.pn h1, .pn h2, .pn h3, .pn p { margin: 0; } +.pn h1, .pn h2, .pn h3 { text-wrap: balance; } +.pn p { text-wrap: pretty; } +.pn a { color: var(--pn-accent); text-decoration: none; } +.pn a:hover { color: var(--pn-accent-hover); text-decoration: underline; } +.pn :focus-visible { outline: 2px solid var(--pn-focus); outline-offset: 2px; } +.pn ::selection { background: var(--pn-brand-200); } +.pn .mono { font-family: var(--mono); font-size: 13px; letter-spacing: -0.01em; } +.pn .muted { color: var(--pn-fg-muted); } +.pn .subtle { color: var(--pn-fg-subtle); } +.pn .strong { font-weight: 500; } +.pn .num { text-align: right; } +.pn .nowrap { white-space: nowrap; } +.pn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.pn .ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } +.pn .ic.s16 { width: 16px; height: 16px; } +.pn .ic.s14 { width: 14px; height: 14px; } +.pn .ic.s20 { width: 20px; height: 20px; } +.pn .glyph { width: 16px; height: 16px; flex: none; } +.pn .glyph.s20 { width: 20px; height: 20px; } +.pn .glyph.s22 { width: 22px; height: 22px; } + +/* Prototype bar: not part of the product. */ +.proto { flex: none; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 8px 16px; min-height: 52px; + background: #15181c; color: #d9dde3; font: 500 12px/16px var(--sans); border-bottom: 2px dashed #e0a43a; } +.proto .tag { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px; border-radius: 6px; background: #e0a43a; color: #1a1406; font-weight: 600; } +.proto label { display: inline-flex; align-items: center; gap: 8px; color: #9aa3ad; } +.proto select { height: 32px; border-radius: 6px; border: 1px solid #3a414a; background: #22272e; color: #f0f2f5; padding: 0 8px; font: 500 13px var(--sans); } +.proto .pseg { display: inline-flex; border: 1px solid #3a414a; border-radius: 6px; overflow: hidden; } +.proto .pseg button { height: 30px; padding: 0 10px; color: #c3c9d1; font: 500 12px var(--sans); } +.proto .pseg button.on { background: #e0a43a; color: #1a1406; } +.proto .grow { flex: 1; } + +.stage { flex: 1; min-height: 0; display: flex; justify-content: center; align-items: stretch; } +.pn[data-device="mobile"] .stage { align-items: center; padding: 16px; } +.app { container: app / inline-size; position: relative; flex: 1; min-width: 0; overflow: hidden; background: var(--pn-bg); display: flex; } +.pn[data-device="mobile"] .app { flex: none; width: 390px; height: min(844px, 100%); border-radius: 28px; box-shadow: 0 0 0 10px #15181c, 0 30px 60px rgb(0 0 0 / 0.3); } + +/* Shell */ +.shell { display: flex; width: 100%; height: 100%; } +.rail { width: 64px; flex: none; background: var(--pn-nav); border-right: 1px solid var(--pn-line); display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 4px; } +.rail-sep { width: 28px; height: 1px; background: var(--pn-line); margin: 6px 0; } +.rail-grow { flex: 1; } +.ri { position: relative; width: 44px; height: 44px; border-radius: 8px; display: grid; place-items: center; color: var(--pn-fg-muted); transition: background-color 120ms, color 120ms; } +.ri:hover { background: var(--pn-hover); color: var(--pn-fg); } +.ri.on { background: var(--pn-nav-active); color: var(--pn-fg); } +.ri.on::before { content: ""; position: absolute; left: -10px; top: 10px; bottom: 10px; width: 3px; border-radius: 0 3px 3px 0; background: var(--pn-accent); } +.ri .dot { position: absolute; top: 9px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--pn-accent); box-shadow: 0 0 0 2px var(--pn-nav); } +.ri .tip { position: absolute; left: 52px; top: 50%; transform: translateY(-50%); white-space: nowrap; padding: 4px 8px; border-radius: 8px; background: var(--pn-surface-raised); color: var(--pn-fg); border: 1px solid var(--pn-line); box-shadow: var(--pn-shadow-float); font-size: 12px; pointer-events: none; opacity: 0; z-index: 30; } +.ri:hover .tip { opacity: 1; transition: opacity 120ms 400ms; } +.logo { width: 32px; height: 32px; border-radius: 50%; display: block; } +.me-av { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); font-size: 11px; font-weight: 600; } + +.panel { width: 240px; flex: none; background: var(--pn-panel); border-right: 1px solid var(--pn-line); display: flex; flex-direction: column; } +.panel-h { height: 64px; display: flex; align-items: center; gap: 10px; padding: 0 18px; font-size: 15px; font-weight: 600; border-bottom: 1px solid var(--pn-line); } +.panel-h .ic { color: var(--pn-fg-muted); } +.panel-list { padding: 8px; display: flex; flex-direction: column; gap: 2px; } +.pi { position: relative; height: 38px; border-radius: 8px; display: flex; align-items: center; gap: 10px; padding: 0 10px; color: var(--pn-fg); text-align: left; width: 100%; transition: background-color 120ms; } +.pi .ic { color: var(--pn-fg-muted); } +.pi:hover { background: var(--pn-hover); } +.pi.on, .pi.anc { background: var(--pn-nav-active); } +.pi.on .ic, .pi.anc .ic { color: var(--pn-accent); } +.pi.on::before { content: ""; position: absolute; left: -8px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--pn-accent); } +.pi .grow { flex: 1; } +.pill-count { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); font: 500 11px/20px var(--mono); text-align: center; } + +.col { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; } +.topbar, .bnav, .pills { display: none; } +.scroll { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--pn-line-strong) transparent; } +.page { max-width: 1280px; margin: 0 auto; padding: 24px 32px 56px; display: flex; flex-direction: column; gap: 20px; } + +.topbar { height: 56px; flex: none; align-items: center; gap: 8px; padding: 0 8px 0 12px; background: var(--pn-nav); border-bottom: 1px solid var(--pn-line); } +.topbar .t { flex: 1; font-size: 16px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pills { gap: 8px; overflow-x: auto; padding: 12px 16px 0; scrollbar-width: none; } +.pill { height: 36px; flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line); background: var(--pn-surface); white-space: nowrap; } +.pill .ic { color: var(--pn-fg-muted); width: 16px; height: 16px; } +.pill.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.pill.on .ic { color: var(--pn-accent); } +.bnav { height: 64px; flex: none; background: var(--pn-nav); border-top: 1px solid var(--pn-line); } +.bn { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 11px; color: var(--pn-fg-muted); min-width: 0; } +.bn.on { color: var(--pn-accent); } +.bn.on::before { content: ""; position: absolute; top: 0; left: 25%; right: 25%; height: 2px; border-radius: 0 0 2px 2px; background: var(--pn-accent); } +.bn .dot { position: absolute; top: 10px; left: calc(50% + 6px); width: 7px; height: 7px; border-radius: 50%; background: var(--pn-accent); } + +@container app (max-width: 1099px) { .panel { width: 200px; } } +@container app (max-width: 759px) { + .rail, .panel { display: none; } + .topbar, .bnav { display: flex; } + .pills { display: flex; } + .page { padding: 16px 16px 32px; gap: 16px; } +} + +/* Page header */ +.ph { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.ph-t { display: flex; align-items: center; gap: 12px; min-width: 0; } +.ph-tile { width: 44px; height: 44px; border-radius: 10px; border: 1px solid var(--pn-line); background: var(--pn-surface); display: grid; place-items: center; color: var(--pn-fg-muted); flex: none; } +.ph h1 { font-size: 24px; line-height: 32px; font-weight: 600; letter-spacing: -0.015em; } +.ph .ctx { color: var(--pn-fg-muted); margin-top: 2px; } +.ph-acts { display: flex; gap: 8px; flex: none; } +.crumbs { display: flex; align-items: center; gap: 8px; min-height: 40px; font-size: 14px; } +.crumbs button.lnk { color: var(--pn-fg-muted); } +.crumbs button.lnk:hover { color: var(--pn-accent); } +.crumbs .sep { color: var(--pn-fg-subtle); } +@container app (max-width: 759px) { + .ph { flex-direction: column; gap: 12px; } + .ph h1 { font-size: 22px; line-height: 28px; } + .ph-acts { width: 100%; } + .ph-acts > * { flex: 1; } +} + +/* Buttons */ +.btn { height: 40px; padding: 0 14px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 500; white-space: nowrap; border: 1px solid transparent; transition: background-color 120ms, color 120ms, border-color 120ms, transform 120ms; } +.btn:active { transform: translateY(1px); } +.btn.sm { height: 36px; padding: 0 12px; } +.btn.primary { background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.btn.primary:hover { background: var(--pn-accent-solid-hover); } +.btn.outline { background: var(--pn-surface); border-color: var(--pn-line-strong); } +.btn.outline:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.btn.ghost { color: var(--pn-fg); } +.btn.ghost:hover { background: var(--pn-hover); color: var(--pn-accent); } +.btn.dtext { color: var(--pn-danger-fg); } +.btn.dtext:hover { color: var(--pn-accent); } +.btn.danger { background: var(--pn-danger-solid); color: #fff; } +.btn.danger:hover { filter: brightness(0.92); } +.pn[data-theme="dark"] .btn.danger { color: #fff; } +.btn .ic { width: 16px; height: 16px; } +.btn.full { width: 100%; } +.ib { width: 36px; height: 36px; border-radius: 8px; display: inline-grid; place-items: center; color: var(--pn-fg-muted); flex: none; transition: background-color 120ms, color 120ms; position: relative; } +.ib:hover { background: var(--pn-accent-soft); color: var(--pn-accent); } +.ib.boxed { border: 1px solid var(--pn-line-strong); background: var(--pn-surface); } +.ib.danger { color: var(--pn-danger-fg); } +.ib.danger:hover { color: var(--pn-accent); } +.ib.ghostslot { visibility: hidden; } +.ib .tipb { position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); white-space: nowrap; padding: 4px 8px; border-radius: 8px; background: var(--pn-surface-raised); color: var(--pn-fg); border: 1px solid var(--pn-line); box-shadow: var(--pn-shadow-float); font-size: 12px; line-height: 16px; pointer-events: none; opacity: 0; z-index: 35; } +.ib:hover .tipb { opacity: 1; transition: opacity 100ms 100ms; } +.kbd { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 4px; border: 1px solid var(--pn-line-strong); font: 500 11px var(--mono); color: var(--pn-fg-muted); } + +/* Toolbar */ +.tb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.search { position: relative; flex: 0 1 340px; min-width: 220px; } +.search .ic { position: absolute; left: 12px; top: 11px; color: var(--pn-fg-muted); width: 18px; height: 18px; } +.search input { width: 100%; height: 40px; border: 1px solid var(--pn-line-strong); border-radius: 8px; background: var(--pn-surface); padding: 0 40px 0 38px; outline: none; } +.search input::placeholder { color: var(--pn-fg-subtle); } +.search input:focus { border-color: var(--pn-focus); box-shadow: 0 0 0 3px color-mix(in oklab, var(--pn-focus) 25%, transparent); } +.search .kbd { position: absolute; right: 10px; top: 10px; } +.fwrap { position: relative; display: inline-flex; } +.fbtn { gap: 6px; padding: 0 10px 0 12px; } +.fbtn .ic { color: var(--pn-fg-muted); } +.fbtn.set { border-color: var(--pn-hover-line); } +.fbtn .fv { color: var(--pn-accent); } +.qf { height: 40px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; transition: background-color 120ms, border-color 120ms; } +.qf:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.qf.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.qf .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.qf.on .n { color: var(--pn-accent); } +.count { font-size: 13px; color: var(--pn-fg-muted); white-space: nowrap; margin-left: 4px; } +.tb-gap { flex: 1; } +.seg { display: inline-flex; height: 40px; padding: 3px; background: var(--pn-muted); border-radius: 8px; gap: 2px; flex: none; max-width: 100%; overflow-x: auto; scrollbar-width: none; } +.seg button { height: 34px; padding: 0 12px; border-radius: 6px; display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; color: var(--pn-fg-muted); border: 1px solid transparent; } +.seg button:hover { color: var(--pn-fg); } +.seg button.on { background: var(--pn-surface); color: var(--pn-fg); border-color: var(--pn-line); } +.seg .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +@container app (max-width: 759px) { + .search { flex: 1 1 100%; min-width: 0; } + .search .kbd { display: none; } + .search input { font-size: 16px; } +} + +/* Menus and popovers */ +.catch { position: fixed; inset: 0; z-index: 39; } +.menu { text-align: left; display: block; position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 220px; max-height: 320px; overflow-y: auto; padding: 4px; background: var(--pn-surface-raised); border-radius: 10px; box-shadow: var(--pn-shadow-float); animation: floatIn 160ms var(--pn-ease-out); transform-origin: top left; } +.menu.right { left: auto; right: 0; transform-origin: top right; } +.mi { width: 100%; height: 36px; padding: 0 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; text-align: left; white-space: nowrap; } +.mi:hover { background: var(--pn-hover); } +.mi .ic { color: var(--pn-fg-muted); width: 16px; height: 16px; } +.mi.danger { color: var(--pn-danger-fg); } +.mi.danger .ic { color: var(--pn-danger-fg); } +.mi .chk { visibility: hidden; color: var(--pn-accent); } +.mi .chk.on { visibility: visible; } +.mi .grow { flex: 1; } +.mi .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.msep { height: 1px; background: var(--pn-line); margin: 4px 0; } +@keyframes floatIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } } + +/* Tables */ +.tsurf { container: tbl / inline-size; background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 12px; } +.tsurf table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; } +.tsurf th { position: sticky; top: 0; z-index: 2; height: 42px; padding: 0 12px; text-align: left; font-size: 12px; font-weight: 500; color: var(--pn-fg-muted); border-bottom: 1px solid var(--pn-line); white-space: nowrap; background: var(--pn-surface); } +.tsurf thead tr th:first-child { border-top-left-radius: 12px; padding-left: 16px; } +.tsurf thead tr th:last-child { border-top-right-radius: 12px; } +.tsurf th.num, .tsurf th.act { text-align: right; } +.tsurf th .sortb { display: inline-flex; align-items: center; gap: 4px; height: 28px; margin: 0 -6px; padding: 0 6px; border-radius: 6px; color: inherit; font-weight: 500; } +.tsurf th .sortb:hover { background: var(--pn-hover); color: var(--pn-fg); } +.tsurf th .sortb .ic { width: 14px; height: 14px; } +.tsurf th .sortb .ic.idle { opacity: 0; } +.tsurf th .sortb:hover .ic.idle { opacity: 1; } +.tsurf td { height: 52px; padding: 0 12px; border-bottom: 1px solid var(--pn-line); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: background-color 120ms; } +.tsurf td:first-child { padding-left: 16px; } +.tsurf td.act { overflow: visible; text-align: right; padding-right: 8px; } +.tsurf td.act .acts { display: inline-flex; gap: 2px; align-items: center; justify-content: flex-end; } +.tsurf td.wrap { white-space: normal; } +.tsurf tbody tr:last-child td { border-bottom: 0; } +.tsurf tbody tr.click { cursor: pointer; } +.tsurf tbody tr.click:hover td, .tsurf tbody tr.hov:hover td { background: var(--pn-accent-soft); } +.tsurf tbody tr.arch td { color: var(--pn-fg-muted); } +.tsurf tbody tr:last-child td:first-child { border-bottom-left-radius: 12px; } +.tsurf tbody tr:last-child td:last-child { border-bottom-right-radius: 12px; } +.tsurf.hasfoot tbody tr:last-child td { border-bottom: 1px solid var(--pn-line); border-radius: 0; } +.vsep { display: inline-block; width: 1px; height: 20px; background: var(--pn-line); margin: 0 4px; vertical-align: middle; } +.who { display: flex; align-items: center; gap: 10px; min-width: 0; } +.who .tx { min-width: 0; display: flex; flex-direction: column; } +.who .nm { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.who .sub { font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.av { width: 32px; height: 32px; border-radius: 50%; flex: none; display: grid; place-items: center; background: var(--pn-muted); color: var(--pn-fg-muted); font-size: 11px; font-weight: 500; letter-spacing: -0.01em; box-shadow: var(--pn-media-ring); } +.av.s24 { width: 24px; height: 24px; font-size: 10px; } +.av.s40 { width: 40px; height: 40px; font-size: 13px; } +.av.s64 { width: 64px; height: 64px; font-size: 20px; } +.av.arch { opacity: 0.6; } +.tfoot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px 8px 16px; min-height: 52px; font-size: 13px; color: var(--pn-fg-muted); flex-wrap: wrap; } +.tfoot .pg { display: flex; align-items: center; gap: 2px; } +.tfoot .pgb { min-width: 32px; height: 32px; border-radius: 8px; display: inline-grid; place-items: center; font: 500 13px var(--sans); color: var(--pn-fg); padding: 0 6px; } +.tfoot .pgb:hover { background: var(--pn-hover); } +.tfoot .pgb.on { background: var(--pn-accent-soft); color: var(--pn-accent); } +.tfoot .pgb.gap { color: var(--pn-fg-subtle); pointer-events: none; } +.tfoot .rpp { display: flex; align-items: center; gap: 8px; } +.tfoot select { height: 32px; border-radius: 8px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); padding: 0 8px; } +.tfoot .pgm { display: none; } + +/* column widths and priorities */ +.w60 { width: 60px; } .w80 { width: 80px; } .w88 { width: 88px; } .w168 { width: 168px; } .w72 { width: 72px; } .w84 { width: 84px; } .w96 { width: 96px; } .w104 { width: 104px; } .w112 { width: 112px; } .w120 { width: 120px; } +.w128 { width: 128px; } .w136 { width: 136px; } .w144 { width: 144px; } .w152 { width: 152px; } .w160 { width: 160px; } .w176 { width: 176px; } +.w184 { width: 184px; } .w200 { width: 200px; } .w220 { width: 220px; } .w240 { width: 240px; } .w260 { width: 260px; } .w280 { width: 280px; } +@container tbl (max-width: 1500px) { .p6 { display: none; } } +@container tbl (max-width: 1240px) { .p5 { display: none; } } +@container tbl (max-width: 1060px) { .p4 { display: none; } } +@container tbl (max-width: 900px) { .p3 { display: none; } } +@container tbl (max-width: 700px) { .p2 { display: none; } } + +@container app (max-width: 759px) { + .tsurf { container-type: normal; } + .tsurf table, .tsurf tbody, .tsurf tr, .tsurf td { display: block; width: auto; } + .tsurf thead { display: none; } + .tsurf tbody tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 16px; border-bottom: 1px solid var(--pn-line); } + .tsurf tbody tr:last-child { border-bottom: 0; } + .tsurf td, .tsurf td:first-child { height: auto; padding: 0; border: 0 !important; font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); white-space: nowrap; overflow: visible; } + .tsurf td.p2, .tsurf td.p3, .tsurf td.p4, .tsurf td.p5, .tsurf td.p6 { display: block; } + .tsurf td.c1 { order: -1; } + .tsurf td.c1 { flex-basis: 100%; font-size: 14px; color: var(--pn-fg); margin-bottom: 2px; } + .tsurf td.mfull { flex-basis: 100%; } + .tsurf td.mhide { display: none !important; } + .tsurf td.act { flex-basis: 100%; text-align: left; padding: 4px 0 0; margin-left: -8px; } + .tsurf td.act .acts { justify-content: flex-start; } + .tsurf td .badge, .tsurf td .chip { font-size: 11px; height: 20px; } + .tsurf td .mono { font-size: 12px; } + .tsurf tbody tr.click:hover td { background: transparent; } + .tsurf tbody tr.click:hover { background: var(--pn-accent-soft); } + .tsurf .chips { flex-wrap: wrap; } + .tfoot .pg, .tfoot .rpp { display: none; } + .tfoot .pgm { display: flex; align-items: center; gap: 4px; } + .tfoot .pgm .pgb { min-width: 36px; height: 36px; } +} + +/* Badges and chips */ +.badge { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; flex: none; vertical-align: middle; } +.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.badge.t-neutral { background: var(--pn-muted); color: var(--pn-fg-muted); } +.badge.t-neutral::before { background: var(--pn-fg-muted); } +.badge.t-brand { background: var(--pn-info-bg); color: var(--pn-info-fg); } +.badge.t-brand::before { background: var(--pn-accent); } +.badge.t-success { background: var(--pn-success-bg); color: var(--pn-success-fg); } +.badge.t-success::before { background: var(--pn-success-solid); } +.badge.t-warning { background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.badge.t-warning::before { background: var(--pn-warning-solid); } +.badge.t-danger { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.badge.t-danger::before { background: var(--pn-danger-solid); } +.chip { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--pn-line); font-size: 12px; font-weight: 500; white-space: nowrap; flex: none; color: var(--pn-fg); vertical-align: middle; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.chip.mono { font-family: var(--mono); font-size: 12px; } +.chip.cnt { border-color: transparent; background: var(--pn-muted); color: var(--pn-fg-muted); font-family: var(--mono); position: relative; overflow: visible; } +.chip.cnt .pop { display: none; position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 160px; padding: 8px 10px; background: var(--pn-surface-raised); color: var(--pn-fg); border-radius: 12px; box-shadow: var(--pn-shadow-float); font: 400 13px/20px var(--sans); white-space: nowrap; flex-direction: column; } +.chip.cnt:hover .pop { display: flex; } +.chip .glyph { width: 14px; height: 14px; } +.chips { display: flex; gap: 4px; align-items: center; min-width: 0; } +.dash { color: var(--pn-fg-subtle); } + +/* Cards */ +.card { background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 12px; min-width: 0; } +.card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 12px 0 16px; min-height: 48px; } +.card-h h2 { font-size: 15px; line-height: 22px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.card-h .acts { display: flex; gap: 4px; align-items: center; } +.card-b { padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 12px; } +.card-b.flush { padding: 8px 0 0; } +.kv { display: flex; flex-direction: column; gap: 10px; } +.kvr { display: grid; grid-template-columns: minmax(120px, 184px) minmax(0, 1fr); gap: 16px; align-items: center; min-height: 22px; } +.kk { color: var(--pn-fg-muted); font-size: 13px; } +.vv { min-width: 0; overflow-wrap: anywhere; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +@container app (max-width: 759px) { .kvr { grid-template-columns: minmax(0, 1fr); gap: 2px; } } +.rowh { padding: 14px 16px 6px; font-size: 12px; font-weight: 500; color: var(--pn-fg-muted); border-top: 1px solid var(--pn-line); } +.rowic { width: 32px; height: 32px; border-radius: 8px; background: var(--pn-muted); color: var(--pn-fg-muted); display: grid; place-items: center; flex: none; } +.rowic .ic { width: 16px; height: 16px; } +.rt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } +.rt2 { font-size: 13px; line-height: 20px; margin-top: 2px; overflow-wrap: anywhere; } +.card-empty { padding: 4px 16px 16px; color: var(--pn-fg-muted); font-size: 13px; } +.card-f { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px 16px; border-top: 1px solid var(--pn-line); } +.card-txt { padding: 4px 16px 16px; white-space: pre-line; } +.grid11 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } +@container app (max-width: 1099px) { .grid11 { grid-template-columns: minmax(0, 1fr); } } +.rh-tile { width: 64px; height: 64px; border-radius: 14px; border: 1px solid var(--pn-line); background: var(--pn-surface); display: grid; place-items: center; color: var(--pn-fg-muted); flex: none; } +.rh-tile .ic { width: 28px; height: 28px; } +.recmini { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--pn-line); border-radius: 10px; } +.cf { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; vertical-align: middle; } +.rows { display: flex; flex-direction: column; } +.row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--pn-line); min-height: 52px; } +.row .grow { flex: 1; min-width: 0; } +.row .meta { font-size: 12px; color: var(--pn-fg-muted); line-height: 16px; } +.row.top { align-items: flex-start; } +.grid2 { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 16px; align-items: start; } +.stack { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.cards3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } +@container app (max-width: 1099px) { .grid2 { grid-template-columns: minmax(0, 1fr); } } +.navcard { text-align: left; background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px; transition: background-color 120ms, border-color 120ms; width: 100%; } +.navcard:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.navcard .t { font-size: 15px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.navcard .lead { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.navcard .stats { display: flex; gap: 16px; font-size: 13px; color: var(--pn-fg-muted); flex-wrap: wrap; } +.navcard .stats b { font-weight: 600; color: var(--pn-fg); font-variant-numeric: tabular-nums; } +.navcard .warn { color: var(--pn-warning-fg); display: inline-flex; gap: 4px; align-items: center; } +.sec-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; } +.sec-h h2 { font-size: 17px; line-height: 24px; font-weight: 600; } + +/* Record header */ +.rec { display: flex; align-items: center; gap: 16px; } +.rec h1 { font-size: 28px; line-height: 32px; font-weight: 600; letter-spacing: -0.02em; } +.rec .l1 { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.rec .meta { color: var(--pn-fg-muted); margin-top: 4px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.rec .roles { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } +@container app (max-width: 759px) { .rec { align-items: flex-start; } .rec h1 { font-size: 24px; line-height: 30px; } .rec .av.s64 { width: 48px; height: 48px; font-size: 16px; } } + +/* Alerts */ +.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; font-size: 14px; } +.alert .ic { width: 16px; height: 16px; margin-top: 2px; } +.alert .grow { flex: 1; min-width: 0; } +.alert .at { font-weight: 500; } +.alert.warning { background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.alert.danger { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.alert.info { background: var(--pn-info-bg); color: var(--pn-info-fg); } +.alert.neutral { background: var(--pn-muted); color: var(--pn-fg-muted); } +.alert .btn { height: 32px; padding: 0 10px; margin: -6px -6px -6px 0; color: inherit; } +.alert .btn:hover { background: color-mix(in oklab, currentColor 10%, transparent); color: inherit; } + +/* Empty and skeleton */ +.empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; padding: 48px 24px; } +.empty .tile { width: 40px; height: 40px; border-radius: 8px; background: var(--pn-muted); color: var(--pn-fg-muted); display: grid; place-items: center; margin-bottom: 4px; } +.empty h2, .empty h3 { font-size: 18px; line-height: 24px; font-weight: 600; letter-spacing: -0.01em; } +.empty p { font-size: 13px; color: var(--pn-fg-muted); max-width: 40ch; } +.empty .acts { display: flex; gap: 8px; margin-top: 8px; } +.sk { background: var(--pn-muted); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; } +.sk.r { border-radius: 999px; } +@keyframes pulse { 50% { opacity: 0.55; } } +.skrow { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 16px; border-top: 1px solid var(--pn-line); } + +/* Forms */ +.form { display: flex; flex-direction: column; gap: 16px; } +.frow { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.frow3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +@container app (max-width: 759px) { .frow, .frow3 { grid-template-columns: minmax(0, 1fr); } } +.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.lbl { font-weight: 500; font-size: 14px; } +.lbl .opt { font-weight: 400; color: var(--pn-fg-muted); } +.inp { width: 100%; height: 40px; border: 1px solid var(--pn-line-strong); border-radius: 8px; background: var(--pn-surface); padding: 0 12px; outline: none; } +.inp.mono { font-family: var(--mono); } +textarea.inp { height: 84px; padding: 10px 12px; resize: vertical; line-height: 20px; } +select.inp { padding-right: 8px; } +.inp:focus { border-color: var(--pn-focus); box-shadow: 0 0 0 3px color-mix(in oklab, var(--pn-focus) 25%, transparent); } +.inp.bad { border-color: var(--pn-danger-solid); } +.hint { font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); } +.err { font-size: 12px; line-height: 16px; color: var(--pn-danger-fg); display: flex; gap: 4px; align-items: center; } +.err .ic { width: 14px; height: 14px; } +.opts { display: flex; flex-direction: column; gap: 8px; } +.optc { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--pn-line-strong); border-radius: 10px; background: var(--pn-surface); text-align: left; width: 100%; transition: background-color 120ms, border-color 120ms; } +.optc:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.optc.on { border-color: var(--pn-accent); background: var(--pn-accent-soft); } +.optc .radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--pn-line-strong); flex: none; margin-top: 1px; display: grid; place-items: center; background: var(--pn-surface); } +.optc.on .radio { border-color: var(--pn-accent); } +.optc.on .radio::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--pn-accent); } +.optc .box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--pn-line-strong); flex: none; margin-top: 1px; display: grid; place-items: center; background: var(--pn-surface); color: transparent; } +.optc.on .box { border-color: var(--pn-accent-solid); background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.optc .box .ic { width: 12px; height: 12px; stroke-width: 3; } +.optc .grow { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.optc .meta { font-size: 12px; color: var(--pn-fg-muted); line-height: 16px; } +.toggles { display: flex; gap: 6px; flex-wrap: wrap; } +.tgl { height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } +.tgl:hover { background: var(--pn-hover); } +.tgl.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.tgl .ic { width: 14px; height: 14px; } + +/* Dialogs */ +.scrim { position: absolute; inset: 0; z-index: 50; background: var(--pn-scrim); display: flex; align-items: center; justify-content: center; padding: 32px 16px; animation: fade 200ms var(--pn-ease-out); } +.dlg { width: 560px; max-width: 100%; max-height: 100%; display: flex; flex-direction: column; background: var(--pn-surface-raised); border-radius: 14px; box-shadow: var(--pn-shadow-modal); animation: dlgIn 200ms var(--pn-ease-out); } +.dlg.confirm { width: 460px; } +.dlg.lg { width: 920px; } +.dlg-h { padding: 20px 24px 0; display: flex; gap: 14px; align-items: flex-start; } +.dlg-h .tile { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.dlg-h .tile.d { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.dlg-h .tile.n { background: var(--pn-muted); color: var(--pn-fg-muted); } +.dlg-h h2 { font-size: 18px; line-height: 24px; font-weight: 600; letter-spacing: -0.01em; } +.dlg-h p { color: var(--pn-fg-muted); margin-top: 4px; } +.dlg-h .grow { flex: 1; min-width: 0; } +.dlg-b { padding: 20px 24px; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 16px; } +.dlg-f { padding: 16px 24px 20px; display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--pn-line); } +.dlg-f .grow { flex: 1; } +.dlg-f.plain { border-top: 0; padding-top: 4px; } +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes dlgIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } } +@keyframes sheetIn { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } } +@container app (max-width: 759px) { + .scrim { align-items: flex-end; padding: 24px 0 0; } + .dlg, .dlg.confirm, .dlg.lg { width: 100%; max-height: 92%; border-radius: 14px 14px 0 0; animation: sheetIn 200ms var(--pn-ease-out); } + .dlg-h { padding: 20px 16px 0; } + .dlg-b { padding: 16px; } + .dlg-f { padding: 12px 16px 16px; } + .dlg-f .btn { flex: 1; } + .dlg-f .grow { display: none; } +} +.steps { display: flex; gap: 4px; padding: 16px 24px 0; flex-wrap: wrap; } +.step { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 4px; border-radius: 999px; color: var(--pn-fg-muted); font-size: 13px; } +.step .no { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; border: 1px solid var(--pn-line-strong); font: 500 12px var(--mono); } +.step.on { color: var(--pn-fg); font-weight: 500; } +.step.on .no { border-color: var(--pn-accent-solid); background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.step.done .no { border-color: transparent; background: var(--pn-accent-soft); color: var(--pn-accent); } +.step .ic { width: 12px; height: 12px; stroke-width: 2.5; } +.step-sep { width: 16px; height: 1px; background: var(--pn-line-strong); align-self: center; } +@container app (max-width: 759px) { .steps { padding: 12px 16px 0; } .step .lab { display: none; } .step.on .lab { display: inline; } } +.drop { border: 1.5px dashed var(--pn-line-strong); border-radius: 12px; padding: 32px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; background: var(--pn-sunken); } +.filecard { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--pn-line-strong); border-radius: 10px; background: var(--pn-surface); text-align: left; width: 100%; } +.filecard:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.filecard.on { border-color: var(--pn-accent); background: var(--pn-accent-soft); } +.filecard .fi { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--pn-success-bg); color: var(--pn-success-fg); flex: none; } +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; } +.stat { border: 1px solid var(--pn-line); border-radius: 10px; padding: 10px 12px; background: var(--pn-surface); } +.stat .v { font-size: 20px; line-height: 28px; font-weight: 600; font-variant-numeric: tabular-nums; } +.stat .l { font-size: 12px; color: var(--pn-fg-muted); } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--pn-line); overflow-x: auto; scrollbar-width: none; } +.tab { position: relative; height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 6px; color: var(--pn-fg-muted); white-space: nowrap; } +.tab:hover { color: var(--pn-fg); } +.tab.on { color: var(--pn-fg); } +.tab.on::after { content: ""; position: absolute; left: 6px; right: 6px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--pn-accent); } +.tab .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.cmp { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); border: 1px solid var(--pn-line); border-radius: 12px; overflow: hidden; } +.cmp .side { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.cmp .side + .side { border-left: 1px solid var(--pn-line); } +.cmp .side h3 { font-size: 13px; font-weight: 500; color: var(--pn-fg-muted); } +.cmp .kv { grid-template-columns: 110px minmax(0, 1fr); gap: 6px 12px; } +.cmp mark { background: var(--pn-warning-bg); color: var(--pn-warning-fg); border-radius: 4px; padding: 0 4px; } +@container app (max-width: 759px) { .cmp { grid-template-columns: minmax(0, 1fr); } .cmp .side + .side { border-left: 0; border-top: 1px solid var(--pn-line); } .cmp .kv { grid-template-columns: minmax(0, 1fr); } } + +/* Progress */ +.bar { display: flex; height: 8px; border-radius: 999px; overflow: hidden; background: var(--pn-muted); gap: 2px; } +.bar span { display: block; height: 100%; } +.bar .b-ok { background: var(--pn-success-solid); } +.bar .b-leave { background: var(--pn-warning-solid); } +.bar .b-acc { background: var(--pn-accent-solid); } +.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 13px; color: var(--pn-fg-muted); } +.legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } +.legend b { color: var(--pn-fg); font-weight: 600; } +.meter { display: flex; align-items: center; gap: 8px; } +.meter .bar { flex: 1; height: 6px; min-width: 60px; } + +/* Toasts */ +.toasts { position: absolute; right: 24px; bottom: 24px; z-index: 70; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; pointer-events: none; } +.toast { display: flex; align-items: center; gap: 10px; min-width: 260px; max-width: 380px; padding: 12px 14px; border-radius: 12px; background: var(--pn-surface-raised); color: var(--pn-fg); box-shadow: var(--pn-shadow-float); animation: toastIn 200ms var(--pn-ease-out); pointer-events: auto; } +.toast .ic { width: 18px; height: 18px; } +.toast.success .ic { color: var(--pn-success-fg); } +.toast.error .ic { color: var(--pn-danger-fg); } +.toast.info .ic { color: var(--pn-accent); } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@container app (max-width: 759px) { .toasts { left: 16px; right: 16px; bottom: 76px; align-items: stretch; } .toast { max-width: none; } } + +/* My area */ +.hero { display: flex; gap: 16px; align-items: center; } +.match { max-width: 560px; margin: 24px auto 0; width: 100%; } +.census-box { border: 1px solid var(--pn-accent); border-radius: 12px; background: var(--pn-surface); } +.inline-link { color: var(--pn-accent); font-weight: 500; } +.inline-link:hover { color: var(--pn-accent-hover); text-decoration: underline; } +.split { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.gap8 { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.gap4 { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } +.col8 { display: flex; flex-direction: column; gap: 8px; } +.qa { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 10px 0; border-top: 1px solid var(--pn-line); } +.qa:first-child { border-top: 0; padding-top: 0; } +.qa .q { font-size: 12px; color: var(--pn-fg-muted); } +.stub { padding: 64px 24px; } diff --git a/docs/people/prototype/prototype.dc.html b/docs/people/prototype/prototype.dc.html new file mode 100644 index 00000000..889a3658 --- /dev/null +++ b/docs/people/prototype/prototype.dc.html @@ -0,0 +1,1899 @@ + + + + +People prototype + + + + + + + + + +
+ + + +
+Prototype + + + + + +
+
+ +
+
+
+ + + + + + + +
+
+ +{{topTitle}} + + + +
+ +
+ + + +
+ + +
+
+ +/ +{{ph.current}} +
+
+ + + + +
+
+
+ + +
+
+ +

{{ph.title}}

{{ph.ctx}}

+
+
+ + +
+
+
+ + +{{SK.label}} + +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + + + +
+ + +
+

{{RH.title}}

{{RH.badge.l}}
+

{{m.t}}

+
{{c}}
+
+
+
+ + +
{{a.text}}
+
+ + +
+ + + +
+
+ + +
+
{{CP.headline}}{{CP.pct}}
+ +
ContinuingLeavingNo answer{{CP.period}}
+
+
+ + + +
+

{{MC.title}}

{{MC.closes}}
+
+

Tell us whether you're staying on. We filled in last year's answers: check them and confirm.

+
+
+
{{MC.yearHint}}
+
+
+
Groups you'll keep administering
Groups you deselect are handed to your Capo Admin.
+
Teams you're continuing with
+
+
+
+
+ +
{{MC.done.title}}{{MC.done.badge}}
{{MC.done.meta}}
+
+
+ + +
+
+ +

Is this you?

+
+

We found a record that matches your sign-in. Linking it shows your roles here and lets you answer the census.

+
{{MATCH.name}}
{{MATCH.id}} · {{MATCH.meta}}
{{c}}
+
{{MATCH.why}}
+
+
+
+ +

HR will check your sign-in

We told HR the matched record isn't you. You'll get an email when your account is linked to the right record.

Waiting for HR
+
+
+
+
+ + +
+

You're not on the volunteer or member lists

If you administer groups, belong to a team or are a member, ask to be added. HR matches your sign-in to your record.

+

Request sent

HR will add you or link you to your record. Sent {{NOREC.on}}.

Waiting for HR
+
+
+ + +
+ +
+ +
+
+

{{cd.title}}{{cd.badge.l}}{{cd.count}}

+
+ + + +
+
+
{{cd.alert.text}}
+ +
+ +
{{r.k}} + +{{p.t}} +{{r.bt}} +{{ch.t}} + +
+
+
+
+

{{cd.text}}

+ +
+ +
{{r.h}}
+ +
+ + + +
+
{{p.t}}{{r.chip}}
+
{{r.meta}}
+
{{p.t}}
+
+{{r.badge.l}} + + + + +
+
+
+
+
+

{{cd.empty}}

+
+
+
+
+
+
+
+ +

{{SECH.title}}

{{SECH.ctx}}
+ + +
+
+ + + + + + + + +{{tb.count}} + +
+
+ + +
+ + + + + + + + + + + + + + +
{{h.label}}
+{{c.lab}}: +
{{c.name}}{{c.sub}}
+ +{{p.t}} +{{c.bt}} +{{c.lead}}{{ch.t}}{{pp}} +{{c.meter.t}} + + + + + + +
+ +
+{{T.foot.text}} +
+
+ +
{{T.foot.mtext}}
+
+
+
+
+ +

{{T.empty.title}}

{{T.empty.text}}

+
+
+
+ + +

Outside this prototype

{{STUB.text}}

+
+ +
+
+
+ + + + +
+
+ + +
+ + +
+

{{dl.title}}

{{dl.desc}}

+
+
+
+ + + + + + + + + + +
+

Change member number?

{{dl.desc}}

+
+
{{dl.err.num}}{{dl.hint}}
+
{{dl.m365}}
+
+
+
+
+ + + + + + +
+

End membership?

{{dl.desc}}

+
+
{{dl.err.reason}}
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
{{t.text}}
+
+ +
+
+
+ +
+ + + diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx index f4de4ee0..b2ebd3c1 100644 --- a/src/components/primitives/chip-overflow.tsx +++ b/src/components/primitives/chip-overflow.tsx @@ -20,7 +20,7 @@ type ChipOverflowProps = Omit, "children"> & { } /** Estimated chip geometry (12px DM Sans): padding and dot, average glyph width, the chip's max width. */ -const CHIP_CHROME = 26 +const CHIP_CHROME = 30 const CHIP_GLYPH = 6.4 const CHIP_MAX = 224 /** The narrowest a chip may truncate to and still show a readable start of its name. */ @@ -28,12 +28,16 @@ const CHIP_MIN = 72 const GAP = 4 const BADGE = 30 +function chipWidth(label: string) { + return Math.min(CHIP_MAX, Math.ceil(CHIP_CHROME + label.length * CHIP_GLYPH)) +} + /** How many chips fit `width` before the "+n" badge, never truncating one below `CHIP_MIN`; at least one. */ function fittingCount(labels: string[], max: number, width: number) { let used = 0 let count = 0 for (const label of labels.slice(0, max)) { - const natural = Math.min(CHIP_MAX, CHIP_CHROME + label.length * CHIP_GLYPH) + const natural = chipWidth(label) const reserve = count + 1 < labels.length ? GAP + BADGE : 0 const room = width - used - (count > 0 ? GAP : 0) - reserve if (room < Math.min(natural, CHIP_MIN)) break @@ -71,13 +75,31 @@ export function ChipOverflow({ const count = width === null ? max : Math.min(max, fittingCount(items.map(chipText), max, width)) const shown = items.slice(0, count) const rest = items.slice(count).map(itemLabel) + // Reserve readable short chips first; a long label should consume the remaining space, not squeeze "Member". + const natural = shown.map((item) => chipWidth(chipText(item))) + const minimum = natural.map((size) => Math.min(size, CHIP_MIN)) + const available = + width === null ? null : Math.max(0, width - GAP * (shown.length - 1) - (rest.length ? GAP + BADGE : 0)) + const growth = natural.reduce((sum, size, index) => sum + size - minimum[index], 0) + const extra = available === null ? growth : Math.max(0, available - minimum.reduce((sum, size) => sum + size, 0)) + const ratio = growth ? Math.min(1, extra / growth) : 0 return (
*]:min-w-0", className)} {...props} > - {shown.map(renderItem)} + {shown.map((item, index) => ( +
+ {renderItem(item)} +
+ ))} {rest.length > 0 && ( Promise | void canSubmit?: boolean + /** `danger` for a destructive submit that needs fields (a required reason), where `ConfirmDialog` can't hold them. */ + submitTone?: "primary" | "danger" /** * Enter in a single-line input submits (default). Turn off for steps whose inputs use Enter themselves, * such as a lookup field; ⌘/Ctrl+Enter in textareas still submits. @@ -79,6 +81,7 @@ export function FormDialog({ submitLabel, onSubmit, canSubmit = true, + submitTone = "primary", submitOnEnter = true, error, notice, @@ -177,7 +180,13 @@ export function FormDialog({ Cancel {submitLabel !== undefined && ( - + {submitLabel} )} diff --git a/src/components/primitives/inline-alert.tsx b/src/components/primitives/inline-alert.tsx index 270500a8..0599459f 100644 --- a/src/components/primitives/inline-alert.tsx +++ b/src/components/primitives/inline-alert.tsx @@ -20,18 +20,29 @@ type InlineAlertProps = { className?: string } -/** Persistent load errors, dialog submit errors, partial-save warnings. Never for success. */ +/** + * Persistent load errors, dialog submit errors, partial-save warnings. Never for success. Below the shell breakpoint + * the action moves under the text, so a narrow screen keeps the message readable. + */ export function InlineAlert({ tone = "danger", children, action, className }: InlineAlertProps) { const { icon: Icon, surface, glyph } = tones[tone] const urgent = tone === "danger" || tone === "warning" return (
{children}
- {action &&
{action}
} + {action && ( +
+ {action} +
+ )}
) } diff --git a/src/components/primitives/key-value-list.tsx b/src/components/primitives/key-value-list.tsx index 852f16bf..6aa53cf1 100644 --- a/src/components/primitives/key-value-list.tsx +++ b/src/components/primitives/key-value-list.tsx @@ -29,7 +29,10 @@ function Value({ item }: { item: KeyValueItem }) { ) } -/** Read-only values. Vertical: one row per item with a 120px key column. Horizontal: items inline on one row. */ +/** + * Read-only values. Vertical: one row per item with a 120px key column, or the key above the value in containers + * narrower than 352px. Horizontal: items inline on one row. + */ export function KeyValueList({ items, orientation = "vertical", className }: KeyValueListProps) { if (orientation === "horizontal") { return ( @@ -44,14 +47,20 @@ export function KeyValueList({ items, orientation = "vertical", className }: Key ) } + // Narrower than 352px (phone cards) the key sits above its value, so values like emails get the full width. return ( -
- {items.map((item) => ( -
-
{item.key}
- -
- ))} -
+
+
+ {items.map((item) => ( +
+
{item.key}
+ +
+ ))} +
+
) } diff --git a/src/components/primitives/record-header.tsx b/src/components/primitives/record-header.tsx index 5ce9c8ad..71b98a0d 100644 --- a/src/components/primitives/record-header.tsx +++ b/src/components/primitives/record-header.tsx @@ -18,7 +18,10 @@ type RecordHeaderProps = { className?: string } -/** The only `h1` on a deep page: optional avatar or label dot, the title with a status badge, meta and description. */ +/** + * The only `h1` on a deep page: optional avatar or label dot, the title with a status badge, meta and description. + * Below the shell breakpoint the actions take their own line, so they never squeeze the title and meta. + */ export function RecordHeader({ title, avatar, @@ -31,7 +34,7 @@ export function RecordHeader({ className, }: RecordHeaderProps) { return ( -
+
{avatar &&
{avatar}
}
@@ -45,7 +48,7 @@ export function RecordHeader({ {description &&

{description}

} {chips &&
{chips}
}
- {actions &&
{actions}
} + {actions &&
{actions}
}
) } diff --git a/src/components/primitives/section-card.tsx b/src/components/primitives/section-card.tsx index b475e54f..3272eb03 100644 --- a/src/components/primitives/section-card.tsx +++ b/src/components/primitives/section-card.tsx @@ -50,7 +50,8 @@ export function SectionCard({ {hasHeader && (
candidate.id === "account")?.path ?? "/dashboard/account" return (
@@ -62,7 +64,7 @@ export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTo service.sections.length > 0) + const services = useVisibleServices().filter(isRailService) const activeId = serviceFor(match).id return ( ) } /** Below 760px: the service's sections as a horizontally scrolling row of pills under the top bar. */ -export function SectionPills({ service, match, pendingReports, className }: PanelProps) { +export function SectionPills({ service, match, pendingReports, needsReview, className }: PanelProps) { const ref = useRef(null) const currentPath = sectionFor(match)?.path useLayoutEffect(() => { @@ -148,7 +159,13 @@ export function SectionPills({ service, match, pendingReports, className }: Pane className )} > - + ) } diff --git a/src/components/shell/rail.tsx b/src/components/shell/rail.tsx index bbba587e..d371b46a 100644 --- a/src/components/shell/rail.tsx +++ b/src/components/shell/rail.tsx @@ -19,10 +19,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils" import { AccountAvatar, type ShellUser } from "./account-avatar" -import { type DashboardPath, type PageMatch, type Service, serviceById, serviceFor } from "./nav" +import { type DashboardPath, isRailService, type PageMatch, type Service, serviceById, serviceFor } from "./nav" import { ServiceGlyph } from "./service-glyph" import { themeToggleLabel, useTheme } from "./theme" -import { useVisibleServices } from "./use-can" +import { useHasAdminAccess, useVisibleServices } from "./use-can" const railItemClass = "relative grid size-10 shrink-0 place-items-center rounded-(--pn-r-3) text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-hover) hover:text-(--pn-fg)" @@ -129,7 +129,8 @@ export function OpenReportsDot({ /** The 64px service rail. One tab stop; ↑/↓, Home and End move between items. */ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, className }: RailProps) { - const panelServices = useVisibleServices().filter((service) => service.sections.length > 0) + const panelServices = useVisibleServices().filter(isRailService) + const hasAdminAccess = useHasAdminAccess() const navRef = useRef(null) const [focusedIndex, setFocusedIndex] = useState(null) const { theme, toggleTheme } = useTheme() @@ -199,10 +200,10 @@ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, > {/* The logo uses the same active fill and left indicator as every other rail item. */} @@ -260,7 +261,7 @@ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, tooltip={user.name || user.email} active={activeId === "account"} focusable={tabStop === accountIndex} - target={{ kind: "link", to: account.path }} + target={{ kind: "link", to: serviceHref(account) }} > diff --git a/src/components/shell/shell.tsx b/src/components/shell/shell.tsx index 57edcf35..227710ae 100644 --- a/src/components/shell/shell.tsx +++ b/src/components/shell/shell.tsx @@ -85,6 +85,8 @@ export type DashboardShellProps = { user: DashboardUser /** Pending reports for the Reports dot and the Reports › Open count; streams in, null when it couldn't be loaded. */ pendingReports: Promise + /** People › Linking's Needs review count; streams in, null when it couldn't be loaded or isn't readable. */ + needsReview: Promise } /** @@ -92,15 +94,15 @@ export type DashboardShellProps = { * service's section pills, then the page), then the bottom nav. Pages render their header through `PageBar` and * their body inside `PageContent`; both scroll with the page. */ -export function DashboardShell({ user, pendingReports }: DashboardShellProps) { +export function DashboardShell({ user, pendingReports, needsReview }: DashboardShellProps) { return ( - + ) } -function DashboardFrame({ user: dashboardUser, pendingReports }: DashboardShellProps) { +function DashboardFrame({ user: dashboardUser, pendingReports, needsReview }: DashboardShellProps) { const pathname = useRenderedPathname() const match = matchPath(pathname) const rawService = serviceFor(match) @@ -176,7 +178,7 @@ function DashboardFrame({ user: dashboardUser, pendingReports }: DashboardShellP exit={{ transform: "translateX(-100%)", transition: reduceMotion ? { duration: 0 } : panelExit }} className="flex h-full shrink-0 min-[1440px]:absolute min-[1440px]:inset-y-0 min-[1440px]:left-0" > - + ) : null} @@ -196,6 +198,7 @@ function DashboardFrame({ user: dashboardUser, pendingReports }: DashboardShellP service={service} match={match} pendingReports={pendingReports} + needsReview={needsReview} className="shell:hidden" /> ) : null} diff --git a/src/components/shell/use-can.ts b/src/components/shell/use-can.ts index eb7d7a18..a182b1a3 100644 --- a/src/components/shell/use-can.ts +++ b/src/components/shell/use-can.ts @@ -2,7 +2,7 @@ import { getRouteApi } from "@tanstack/react-router" import { hasPermission, type Permission } from "@/server/permissions" -import { services } from "./nav" +import { NO_PEOPLE_ROLES, sectionVisible, services } from "./nav" const dashboardRoute = getRouteApi("/dashboard") @@ -15,16 +15,26 @@ export function useCan(permission: Permission) { return hasPermission(permissions, permission) } -/** The sections available to the user, including read-specific and dedicated member permissions. */ +/** Whether the user has the full dashboard (`admin:access`), not only the restricted People view. */ +export function useHasAdminAccess() { + return dashboardRoute.useRouteContext({ select: (context) => hasPermission(context.permissions, "admin:access") }) +} + +/** The Capo Admin scope and led teams the signed-in person's record gives them. */ +export function usePeopleRoles() { + return dashboardRoute.useRouteContext({ select: (context) => context.peopleRoles ?? NO_PEOPLE_ROLES }) +} + +/** + * The sections available to the user: permissions, `admin:access` for everything but Account, and the roles from + * their People record (Capo Admin, Team Lead). + */ export function useVisibleServices() { const permissions = dashboardRoute.useRouteContext({ select: (context) => context.permissions }) + const roles = usePeopleRoles() return services.flatMap((service) => { - if (service.sections.length === 0) return [service] - const sections = service.sections.filter( - (section) => - (!section.permission || hasPermission(permissions, section.permission)) && - (!section.anyPermission || section.anyPermission.some((permission) => hasPermission(permissions, permission))) - ) + if (service.sections.length === 0) return hasPermission(permissions, "admin:access") ? [service] : [] + const sections = service.sections.filter((section) => sectionVisible(section, permissions, roles)) return sections.length ? [{ ...service, sections, path: sections[0].path }] : [] }) } diff --git a/src/components/telegram/grant-date-time-fields.tsx b/src/components/telegram/grant-date-time-fields.tsx index 221eb31c..18ccec05 100644 --- a/src/components/telegram/grant-date-time-fields.tsx +++ b/src/components/telegram/grant-date-time-fields.tsx @@ -1,9 +1,8 @@ import { CalendarIcon, Clock3 } from "lucide-react" -import { type ReactNode, useEffect, useMemo, useState } from "react" +import { type ReactNode, useMemo, useState } from "react" import { buttonMotion, - fieldControl, fieldHintId, floatingMotion, FormField, @@ -12,7 +11,6 @@ import { } from "@/components/primitives" import { Button } from "@/components/ui/button" import { Calendar } from "@/components/ui/calendar" -import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { WheelPicker, WheelPickerColumn } from "@/components/ui/wheel-picker" import { formatDate } from "@/lib/format" @@ -56,25 +54,12 @@ function clampToMinimum(date: Date, minimum?: Date) { return minimum && date < minimum ? new Date(minimum) : date } -/** Native `datetime-local` on touch devices; the calendar and wheel popovers on fine pointers. */ -function useCoarsePointer() { - const [coarse, setCoarse] = useState(false) - useEffect(() => { - const query = window.matchMedia("(pointer: coarse)") - setCoarse(query.matches) - const onChange = (event: MediaQueryListEvent) => setCoarse(event.matches) - query.addEventListener("change", onChange) - return () => query.removeEventListener("change", onChange) - }, []) - return coarse -} - const HOUR_VALUES = Array.from({ length: 24 }, (_, hour) => pad(hour)) const MINUTE_VALUES = Array.from({ length: 60 }, (_, minute) => pad(minute)) const pickerButton = cn( buttonMotion, - "h-9 justify-start gap-2 border-(--pn-line-strong) bg-(--pn-surface) px-3 text-sm font-normal text-(--pn-fg) shadow-none hover:bg-(--pn-hover) aria-invalid:border-(--pn-danger-solid) [&_svg]:text-(--pn-fg-muted)" + "h-9 justify-start gap-2 border-(--pn-line-strong) bg-(--pn-surface) px-3 text-sm font-normal text-(--pn-fg) shadow-none pointer-coarse:h-10 pointer-coarse:text-base hover:bg-(--pn-hover) aria-invalid:border-(--pn-danger-solid) [&_svg]:text-(--pn-fg-muted)" ) type DateTimeInputProps = { @@ -87,7 +72,7 @@ type DateTimeInputProps = { disabled: boolean } -function DesktopDateTimeInput({ id, label, value, onChange, minimum, invalid, disabled }: DateTimeInputProps) { +function DateTimeInput({ id, label, value, onChange, minimum, invalid, disabled }: DateTimeInputProps) { const selected = parseLocalDateTime(value) const [dateOpen, setDateOpen] = useState(false) const [timeOpen, setTimeOpen] = useState(false) @@ -166,13 +151,14 @@ function DesktopDateTimeInput({ id, label, value, onChange, minimum, invalid, di @@ -233,25 +219,6 @@ function DesktopDateTimeInput({ id, label, value, onChange, minimum, invalid, di ) } -function DateTimeInput(props: DateTimeInputProps) { - const coarse = useCoarsePointer() - if (!coarse) return - const { id, value, onChange, minimum, invalid, disabled } = props - return ( - onChange(event.target.value)} - aria-invalid={invalid || undefined} - aria-describedby={invalid ? fieldHintId(id) : undefined} - disabled={disabled} - className={cn("h-10 w-full min-w-0 bg-(--pn-surface) min-[480px]:flex-1", fieldControl)} - /> - ) -} - const DURATIONS = [ { label: "2h", hours: 2 }, { label: "6h", hours: 6 }, diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 218964c4..bf4f8d1b 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -162,6 +162,7 @@ function CalendarDayButton({ className )} {...props} + ref={ref} /> ) } diff --git a/src/env.ts b/src/env.ts index 3dc6dccd..1040ed07 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,10 @@ export const env = createEnv({ .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), + /** Development only: comma-separated permissions for the agent-mode user (default: all of them). */ + AGENT_PERMISSIONS: z.string().optional(), + /** Development only: the agent-mode user's person record in the People sample data. */ + AGENT_PERSON: z.string().default("P-0007"), NODE_ENV: z.enum(["development", "test", "production"]), BACKEND_URL: z.url(), }, diff --git a/src/features/account/account-page.tsx b/src/features/account/account-page.tsx index ff0c0bab..43acce4c 100644 --- a/src/features/account/account-page.tsx +++ b/src/features/account/account-page.tsx @@ -33,7 +33,7 @@ type AccountPageProps = { export function AccountPage({ user, permissions, accountUrl }: AccountPageProps) { return ( <> - +
diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx index 525e8a93..4a748765 100644 --- a/src/features/dashboard/overview-page.tsx +++ b/src/features/dashboard/overview-page.tsx @@ -3,7 +3,15 @@ import { ArrowRight, ArrowUp, ChevronRight, Search } from "lucide-react" import { type ReactNode, Suspense, use, useState } from "react" import { InlineAlert, LoadingButton, SegmentedControl, useModifierKey } from "@/components/primitives" -import { type DashboardPath, PageBar, PageContent, type Service, ServiceGlyph, useShellFrame } from "@/components/shell" +import { + type DashboardPath, + isRailService, + PageBar, + PageContent, + type Service, + ServiceGlyph, + useShellFrame, +} from "@/components/shell" import { useVisibleServices } from "@/components/shell/use-can" import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" @@ -268,7 +276,7 @@ function chartDescription(points: ReturnType["points"], ran /** Every service the user can open: the whole card leads where its rail icon does, each section is a 44px button. */ function Areas({ openReports }: { openReports: OverviewCount }) { - const services = useVisibleServices().filter((service) => service.sections.length > 0) + const services = useVisibleServices().filter(isRailService) return (

diff --git a/src/features/people/account-action-dialog.tsx b/src/features/people/account-action-dialog.tsx new file mode 100644 index 00000000..66841810 --- /dev/null +++ b/src/features/people/account-action-dialog.tsx @@ -0,0 +1,82 @@ +import { useServerFn } from "@tanstack/react-start" +import { Mail, RefreshCw, Unlink, X } from "lucide-react" + +import { ConfirmDialog } from "@/components/primitives" + +import { useRefreshAfter } from "./people-hooks" +import { revokePersonInvite, sendPersonInvite, unlinkPersonAccount } from "./people-write.functions" + +export type AccountAction = { + kind: "invite" | "resend" | "revoke" | "unlink" + personId: string + name: string + /** The invite address, or the linked sign-in for `unlink`. */ + email: string +} + +/** Send, resend or revoke an invite, or unlink an account. Keep `action` after closing so the text survives the exit. */ +export function AccountActionDialog({ + action, + open, + onOpenChange, +}: { + action: AccountAction | null + open: boolean + onOpenChange: (open: boolean) => void +}) { + const send = useServerFn(sendPersonInvite) + const revoke = useServerFn(revokePersonInvite) + const unlink = useServerFn(unlinkPersonAccount) + const refresh = useRefreshAfter() + if (!action) return null + const data = { personId: action.personId } + + if (action.kind === "invite" || action.kind === "resend") { + const resend = action.kind === "resend" + return ( + { + await send({ data }) + await refresh(resend ? "Invite resent." : "Invite sent.") + }} + /> + ) + } + if (action.kind === "revoke") { + return ( + { + await revoke({ data }) + await refresh("Invite revoked.") + }} + /> + ) + } + return ( + { + await unlink({ data }) + await refresh("Account unlinked.") + }} + /> + ) +} diff --git a/src/features/people/admin-dialogs.tsx b/src/features/people/admin-dialogs.tsx new file mode 100644 index 00000000..9fd5b10a --- /dev/null +++ b/src/features/people/admin-dialogs.tsx @@ -0,0 +1,287 @@ +import { useServerFn } from "@tanstack/react-start" +import { Crown } from "lucide-react" +import { useEffect, useState } from "react" + +import { ConfirmDialog, fieldControl, fieldHintId, FormDialog, FormField, Spinner } from "@/components/primitives" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +import { setCapoScope } from "./people-appoint.functions" +import { personName } from "./people-format" +import { errorMessage, useRefreshAfter } from "./people-hooks" +import { SelectField } from "./people-ui" +import { endAdminRole, setCapoOfReference } from "./people-write.functions" +import { COURSES, type PersonRow } from "./people.contract" +import { getPeople } from "./people.functions" + +const END_REASONS = ["Graduated", "No longer has time", "Left the association", "Removed by HR", "Other"] as const +type EndReason = (typeof END_REASONS)[number] + +export type AdminTarget = { personId: string; name: string; groups: number } + +/** Ends an admin role with a reason; the bot removes their rights in every group they administer. */ +export function EndAdminDialog({ + open, + onOpenChange, + target, +}: { + open: boolean + onOpenChange: (open: boolean) => void + target: AdminTarget +}) { + const end = useServerFn(endAdminRole) + const refresh = useRefreshAfter() + const [reason, setReason] = useState(null) + const [details, setDetails] = useState("") + const [submitted, setSubmitted] = useState(false) + const reasonError = submitted && !reason ? "Choose a reason." : undefined + const detailsError = submitted && reason === "Other" && !details.trim() ? "Describe the reason." : undefined + + async function submit() { + setSubmitted(true) + if (!reason || (reason === "Other" && !details.trim())) return + const text = reason === "Other" ? details.trim() : details.trim() ? `${reason}: ${details.trim()}` : reason + try { + await end({ data: { personId: target.personId, reason: text } }) + } catch (caught) { + console.error(caught) + const fallback = "The admin role could not be ended." + throw new Error(caught instanceof Error ? errorMessage(caught, fallback) : fallback, { cause: caught }) + } + await refresh("Admin role ended.") + onOpenChange(false) + } + + return ( + +
+ + + id="end-admin-reason" + value={reason} + options={END_REASONS.map((value) => ({ value, label: value }))} + onChange={setReason} + placeholder="Choose a reason" + invalid={!!reasonError} + /> + + +