From 7ab5098569ac17175cd41126aef8207c7a8c1508 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Oct 2026 00:44:00 +0100 Subject: [PATCH] refactor(people): disconnect Soci, fees, receipts and census, keeping them as mockups Member register, Receipts, Census and My area's fee card become unconnected UI mockups: static sample data from mockup-data.ts, no loaders or server functions, disabled actions and a shared MockupBanner linking Phase 2 (#144) or Phase 3 (#145), gated by people:read. Removed: the members, fees, receipts and census server functions, contract entries and agent-mode data; the eSign receipt bridge (peopleReceipt); Telegram group columns and Capo Admin join requests; the Linking review queue and My area's first-sign-in match, Ask to be added, census answer, correction request and photo-consent revocation; the permissions people:members:read, people:members:write, people:fees:write and people:receipts:sign. The schemas no longer require the fields the backend drops in PoliNetworkOrg/backend#104 and strip them while the current backend still sends them; a review account reads as not linked and a My area match as no record. Closes #138 --- AGENTS.md | 8 +- README.md | 49 +- docs/esign/contracts.md | 29 +- docs/people/README.md | 11 +- docs/people/brief.md | 4 +- docs/people/contract.md | 363 +----- docs/people/decisions.md | 42 +- docs/people/fees.md | 6 + docs/people/permissions.md | 26 +- docs/people/plan.md | 10 + docs/people/verification.md | 5 + src/components/shell/nav.ts | 11 +- src/features/esign/batch-page.tsx | 14 +- src/features/esign/esign.functions.ts | 9 +- src/features/esign/receipt-request-link.tsx | 22 - src/features/esign/receipt-source.ts | 7 - src/features/esign/request-page.tsx | 49 +- src/features/esign/requests-page.tsx | 10 +- src/features/people/admin-dialogs.tsx | 7 +- src/features/people/admins-page.tsx | 144 +-- src/features/people/capo-page.tsx | 121 +- src/features/people/census-answers-dialog.tsx | 58 - src/features/people/census-import.server.ts | 53 - src/features/people/census-page.tsx | 403 +++--- src/features/people/fee-dialogs.tsx | 536 -------- src/features/people/import-wizard.tsx | 589 --------- src/features/people/linking-page.tsx | 537 +------- src/features/people/member-dialogs.tsx | 343 ------ src/features/people/members-page.tsx | 568 +++------ src/features/people/mockup-banner.tsx | 41 + src/features/people/mockup-data.ts | 254 ++++ src/features/people/my-area-dialogs.tsx | 260 ---- src/features/people/my-area-page.tsx | 435 +------ src/features/people/my-fee-card.tsx | 299 +---- src/features/people/overview-page.tsx | 61 +- src/features/people/people-capo.functions.ts | 16 +- .../people/people-census.functions.ts | 47 - src/features/people/people-fees.functions.ts | 61 - src/features/people/people-fees.ts | 127 -- src/features/people/people-files.ts | 25 - src/features/people/people-format.ts | 100 +- src/features/people/people-hooks.ts | 22 - .../people/people-members.functions.ts | 33 - .../people/people-receipts.functions.ts | 78 -- src/features/people/people-self.functions.ts | 69 +- src/features/people/people-write.functions.ts | 14 - src/features/people/people.api.server.ts | 136 -- src/features/people/people.contract.ts | 482 +------- src/features/people/people.fake.server.ts | 1094 +---------------- src/features/people/people.fixtures.server.ts | 712 +---------- src/features/people/people.functions.ts | 13 +- src/features/people/person-dialog.tsx | 4 +- src/features/people/person-page.tsx | 447 +------ src/features/people/receipt-dialogs.tsx | 309 ----- src/features/people/receipt-files.server.ts | 287 ----- src/features/people/receipts-page.tsx | 486 +++----- src/features/people/team-dialogs.tsx | 6 +- src/features/people/teams-page.tsx | 45 +- src/lib/api/types.ts | 6 +- src/routes/dashboard.tsx | 30 +- .../dashboard/people/census/$campaignId.tsx | 21 +- src/routes/dashboard/people/census/index.tsx | 19 +- src/routes/dashboard/people/linking.tsx | 10 +- src/routes/dashboard/people/members.tsx | 31 +- src/routes/dashboard/people/receipts.tsx | 40 +- src/routes/dashboard/telegram/capo.tsx | 2 +- src/server/auth.middleware.ts | 34 +- src/server/permissions.ts | 4 - tests/people-access.test.mjs | 177 +-- tests/people-esign.test.mjs | 243 ---- tests/people-fees.test.mjs | 181 --- tests/people-input.test.mjs | 133 +- tests/people-navigation.test.mjs | 62 + tests/server-security.test.mjs | 62 +- 74 files changed, 1439 insertions(+), 9613 deletions(-) delete mode 100644 src/features/esign/receipt-request-link.tsx delete mode 100644 src/features/esign/receipt-source.ts delete mode 100644 src/features/people/census-answers-dialog.tsx delete mode 100644 src/features/people/census-import.server.ts delete mode 100644 src/features/people/fee-dialogs.tsx delete mode 100644 src/features/people/import-wizard.tsx delete mode 100644 src/features/people/member-dialogs.tsx create mode 100644 src/features/people/mockup-banner.tsx create mode 100644 src/features/people/mockup-data.ts delete mode 100644 src/features/people/my-area-dialogs.tsx delete mode 100644 src/features/people/people-census.functions.ts delete mode 100644 src/features/people/people-fees.functions.ts delete mode 100644 src/features/people/people-fees.ts delete mode 100644 src/features/people/people-files.ts delete mode 100644 src/features/people/people-members.functions.ts delete mode 100644 src/features/people/people-receipts.functions.ts delete mode 100644 src/features/people/receipt-dialogs.tsx delete mode 100644 src/features/people/receipt-files.server.ts delete mode 100644 tests/people-esign.test.mjs delete mode 100644 tests/people-fees.test.mjs create mode 100644 tests/people-navigation.test.mjs diff --git a/AGENTS.md b/AGENTS.md index d12ff51e..8cd2500e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,11 +26,9 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de 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. Fees and receipts (`docs/people/fees.md`): `people:fees:write` reviews, marks paid and reminds; - `people:receipts:sign` downloads and uploads signed receipts for the officer on the caller's record. - Preparing CIE requests additionally requires `esign:sign` and reuses the existing eSign UI and agent; the backend - owns the durable receipt/request association and completion (the People backend contract is still pending). Handoff notes, - decisions and the plan: `docs/people/`. + sections. Member register, Receipts, Census and My area's fee card are unconnected mockups (#138): static sample data + from `mockup-data.ts`, no server functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be + rebuilt in Phase 2 (#144) and Phase 3 (#145). 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 afedd772..b22dc492 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,13 @@ read-only list of groups. Directory access requires `azure:members:read`; creati `azure:members:create`, and changing member IDs `azure:members:write`. Groups require `azure:groups:read`. Group membership is never edited: creating a member adds only that new user to Soci, which gives them the Socio role. -**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`, `people:fees:write` (review transfer receipts, mark fees -paid, reminders), `people:receipts:sign` (the President and Vice-president sign receipts), 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` -and, for fees and receipts, `docs/people/fees.md`. +**People** keeps one record per volunteer (Overview, Admins, Teams, 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`); agent mode serves sample data from +`people.fake.server.ts`. Member register, Receipts and Census are unconnected UI mockups with static sample data, to be +rebuilt in Phase 2 (#144) and Phase 3 (#145). Permissions: `people:read` (which also opens the mockups), +`people: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 @@ -33,23 +32,23 @@ Signing in as a real user needs a development client registered in the IdP. Set the following runtime variables (the build does not need secrets): -| Variable | Meaning | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | -| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | -| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | -| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | -| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | -| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | -| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | -| `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 and President). `P-0098` is a Capo Admin and the Vice-president, `P-0034` the IT Team Lead, `P-0115` an IT member, `P-0045` has a fee receipt to review, `P-0021` a rejected one; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. Sending an invite prints its link in the dev server's console. | -| `AGENT_MODE` | Fake all-permission user in development only. It has no access token, so the backend refuses its calls; People serves sample data. 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. | +| Variable | Meaning | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | +| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | +| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | +| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | +| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | +| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | +| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | +| `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; `none` previews a sign-in without a record. Sending an invite prints its link in the dev server's console. | +| `AGENT_MODE` | Fake all-permission user in development only. It has no access token, so the backend refuses its calls; People serves sample data. 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. | Before review run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Run real Redis tests with `TEST_REDIS_URL=redis://127.0.0.1: pnpm test`. The OIDC integration test uses a local fake provider with diff --git a/docs/esign/contracts.md b/docs/esign/contracts.md index 3240b5c3..fba0570f 100644 --- a/docs/esign/contracts.md +++ b/docs/esign/contracts.md @@ -144,8 +144,7 @@ closes its dialog, so signing again is one click away. ## Backend tRPC (`esign.*`) Permissions: `esign:sign` (see own inbox, sign when it is your turn), `esign:requests:manage` (create, cancel, see all, -browse OneDrive). Requests are visible to their signers and creator. People receipt requests are visible to -`admin:access` + `people:members:read`, or to the officer currently assigned to the receipt. +browse OneDrive). Requests are visible to their signers and creator. - `esign.profile.sync({name, email})` → upserts the caller's signer profile (BFF passes ID-token claims). - `esign.signers.list()` → registered IdP users with `esign:sign`, including those who have not opened Signatures; each includes eligible drives (manage). @@ -185,29 +184,3 @@ permissions}, updatedBySub, updatedAt}], catalog: {roles: [{key, name}], permiss Placement: `{page (0-based), x, y, width, height}` as fractions of the displayed page (crop box after rotation), origin top-left. - -## People fee receipts - -People › Receipts reuses this signing flow through `people.receipts.prepareSignatures({receiptIds})` (1–10 distinct -IDs), then passes the returned `requestId`s to `esign.jobs.create`. The bridge requires the People receipt-signing -permission, the actual President/Vice-president and `esign:sign`; it does not require general request management. -The backend fixes the issued PDF, officer and placement, persists receipt/request ownership and completes/delivers -receipts after eSign completion without a browser callback. Retrying preparation reuses the request. Manual -ZIP/download/upload remains supported; both paths share completion and concurrency guards. The backend implements it -in the `people.*` router (PoliNetworkOrg/backend#83): see [`../people/contract.md`](../people/contract.md#fees-and-receipts). - -Receipt request provenance: `esign.requests.list` rows and `esign.requests.get().request` include -`peopleReceipt: { receiptId: string, number: string, year: "YYYY/YY" }` for the bridge's requests. Other requests omit -it or return null; absence remains supported for deployed eSign versions. The backend fixes the title to -`Receipt {number} · {member name}` and owns this metadata; generic create inputs cannot set it. The dashboard links -the reference to `/dashboard/people/receipts?year={year}&q={number}` when the viewer can read the register. - -For these system-owned requests, jobs/create, signed-file uploads and completion additionally enforce -`people:receipts:sign`, the confirmed officer and receipt assignment/version; `myTurn` reflects that eligibility. -Reject custom placement changes, generic cancellation, deletion and delivery retry: People emails the receipt, so -eSign records no output or email for it. Voiding the receipt cancels its request and logs a `people_invalidated` -event. The signed-file upload also accepts a CAdES `.p7m` around the issued PDF; eSign keeps the PDF inside it, so -the signed copy is the one in People › Receipts. People voiding owns request/job invalidation; -manual upload or CIE completion uses the same transaction and exactly-once delivery guard. Decline/failure leaves -the receipt `toSign`, allowing preparation to reuse an active request or create its replacement. No browser callback -completes a receipt. The agent protocol is unchanged. diff --git a/docs/people/README.md b/docs/people/README.md index 9a8434cd..2b42180e 100644 --- a/docs/people/README.md +++ b/docs/people/README.md @@ -2,12 +2,21 @@ Everything an agent needs to build, or continue building, the People area of the dashboard. +> [!IMPORTANT] +> **Cleanup (#138).** Soci (the member register), fees and receipts, and the census are now **unconnected UI +> mockups**: their pages render static sample data from `src/features/people/mockup-data.ts`, make no backend call, +> disable every action and show a banner. Soci, fees and receipts will be rebuilt from scratch on Microsoft 365 members +> in Phase 2 (#144), the census in Phase 3 (#145). Record matching (the Linking review queue, merging, My area's "Is +> this you?", Ask to be added) and Telegram group tracking (group columns, join requests) are removed. The permissions +> `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` are gone; the mockups +> open with `people:read`. Sections below that describe these features describe the mockup or the removed design. + | 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 | -| [`fees.md`](fees.md) | Fees and receipts: renewal flow, signing, reminders, screens and the receipt PDF | +| [`fees.md`](fees.md) | Fees and receipts as designed; now an unconnected mockup until Phase 2 (#144) | | [`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) | diff --git a/docs/people/brief.md b/docs/people/brief.md index 7fb8df8c..fd4dd67a 100644 --- a/docs/people/brief.md +++ b/docs/people/brief.md @@ -1,7 +1,9 @@ # 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. +conflicts with [`decisions.md`](decisions.md), the decisions win: they record what was settled afterwards. Since #138 +the brief's Member register, census and record-matching parts are unconnected mockups or removed (see +[`README.md`](README.md)). --- diff --git a/docs/people/contract.md b/docs/people/contract.md index 8c32c0b7..2310a1a9 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -6,18 +6,28 @@ truth** for every shape and input (zod schemas); where this file differs, the co 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"` and start on 1 September: the -dashboard labels the current census and fee year from today's date (`currentYear()` in `people-format.ts`). +(`Europe/Rome`); timestamps ISO 8601. + +> [!IMPORTANT] +> **Removed from the contract (#138).** Soci (the member register), fees and receipts, the census, record matching +> and Telegram group tracking are no longer part of People. The dashboard no longer calls `people.members.*`, +> `people.fees.*`, `people.receipts.*`, `people.census.*`, `people.linking.reviews` / `resolve` / `dismiss`, +> `people.capo.approve` / `decline`, or `people.me.confirmMatch` / `rejectMatch` / `answerCensus` / +> `requestCorrection` / `revokePhotoConsent` / `uploadTransferReceipt` / `receipt` / `askToBeAdded`, and the backend +> drops them in PoliNetworkOrg/backend#104 (issue PoliNetworkOrg/backend#100). The Member register, Receipts and Census pages stay as unconnected UI mockups with their +> own sample data (`src/features/people/mockup-data.ts`), to be rebuilt in Phase 2 (#144, Soci and fees on Microsoft +> 365 members) and Phase 3 (#145, the census). +> +> Until the backend cleanup deploys, the backend still sends `member`, `isMember`, `census`, `fees`, `admin.groups`, +> `admin.requested`, `capo.get`'s `groups` and `requests`, and in My area `feeInstructions`, `campaign`, the `none` +> state's `request` and the contacts' and roster's `email`. The dashboard's schemas strip unknown keys, so these are +> ignored, and nothing requires them. An account in `review` reads as `none`, and a My area `match` as `none`. ## 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 AccountStatus = "none" | "invite" | "linked" type TeamId = "it" | "hr" | "design" | "intl" | "events" type TeamRole = "member" | "lead" type Campus = "Leonardo" | "Bovisa" | "Cremona" | "Piacenza" | "Mantova" | "Lecco" | "External" @@ -36,21 +46,11 @@ type Profile = { 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" @@ -65,15 +65,6 @@ type TeamMembership = { 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" } | { @@ -90,7 +81,6 @@ type Account = linkedOn: string how: string } - | { status: "review"; reviewId: string; issue: string } // Every list row: the record without notes, discrepancies and activity. type PersonRow = Omit @@ -98,11 +88,8 @@ type PersonRow = Omit 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 @@ -110,7 +97,7 @@ type PersonRecord = { account: Account notes: string | null // null without people:read discrepancies: { - kind: "declared-member" | "telegram-mismatch" | "needs-review" | "admin-removal" + kind: string // the dashboard acts on "telegram-mismatch" only message: string suggestedTelegram?: string }[] @@ -122,121 +109,43 @@ Team fields (`TEAM_FIELDS`): `it` → `github` (text), `areas` (Frontend, Backen `tools` (Figma, Illustrator, Photoshop, After Effects, InDesign, Premiere), `portfolio` (URL); `hr` and `events` → `focus` (multi); `intl` → `languages` (multi). -## Register projection - -`people.members.list()` returns `MemberRegisterRow[]` with `admin:access` + `people:members:read`, independently -of `people:read`. Each row is `{ id, profile: { firstName, lastName, personalEmail }, member, account: { status } }`. -Include past/current members and names of non-members for the existing admission picker. `personalEmail` is null -without `people:read`. Never include volunteer fields, census, consents, phone, notes or account-link details. -The UI links to full person records only with `people:read`. Write permissions do not imply read permissions. - ## Procedures -| Procedure | Kind | Input | Output | Needs | -| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `people.persons.list` | query | — | `PersonRow[]` | `people:read` | -| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | -| `people.persons.create` | mutation | `Profile` fields + `{ admin: boolean }` | `{ id }` | `people:write` | -| `people.persons.update` | mutation | `{ id } & Partial` | `void` | `people:write` | -| `people.persons.setNote` | mutation | `{ id, note: string }` (empty string clears the note) | `void` | `people:write` | -| `people.members.create` | mutation | `{ personId, number, admittedOn, fee }` | `void` | `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.members.list` | query | — | `MemberRegisterRow[]` | `people:members:read` | -| `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: PersonRow[] }[]` (members include ended memberships) | `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, people: PersonRow[] }` | `people:read` | -| `people.census.answers` | query | `{ personId, year }` | `{ question, answer }[]` | `people:read` | -| `people.census.remind` | mutation | `{ campaignId, personId: PersonId \| null }` (`null` reminds everyone) | `{ 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: PersonRow[], 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.roles` | query | — | `{ capoScope: string \| null, leads: TeamId[] }` (caller's Capo Admin scope and led teams for navigation) | `people:self` | -| `people.me.confirmMatch` | mutation | — | `void` | `people:self` | -| `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `people:self` | -| `people.me.acceptInvite` | mutation | `{ token }` (43-character base64url, from the invite link `/dashboard/account/me?peopleInvite=`) | `void` (links the caller's account to the invited record) | `people:self` | -| `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | -| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepGroups: chatId[], keepTeams: TeamId[], reason?, note? }` | `void` (`CONFLICT` once answered: the answer is final) | `people:self` | -| `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | -| `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | +| Procedure | Kind | Input | Output | Needs | +| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `people.persons.list` | query | — | `PersonRow[]` | `people:read` | +| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | +| `people.persons.create` | mutation | `Profile` fields + `{ admin: boolean }` | `{ id }` | `people:write` | +| `people.persons.update` | mutation | `{ id } & Partial` | `void` | `people:write` | +| `people.persons.setNote` | mutation | `{ id, note: string }` (empty string clears the note) | `void` | `people: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: PersonRow[] }[]` (members include ended memberships) | `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.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, admins: PersonRow[] }` (active admins whose Capo Admin of reference is the caller) | Capo Admin (scope from record) | +| `people.me.get` | query | — | `MyArea` (below) | `people:self` | +| `people.me.roles` | query | — | `{ capoScope: string \| null, leads: TeamId[] }` (caller's Capo Admin scope and led teams for navigation) | `people:self` | +| `people.me.acceptInvite` | mutation | `{ token }` (43-character base64url, from the invite link `/dashboard/account/me?peopleInvite=`) | `void` (links the caller's account to the invited record) | `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: { @@ -245,149 +154,19 @@ type MyArea = 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 } -``` - -## Fees and receipts - -The flow, screens and PDF template are in [`fees.md`](fees.md). `PersonRecord` gains `fees` (`null` for people who -were never members, or without `people:members:read` except on the caller's own record), and the linked `MyArea` gains -`feeInstructions` (bank details while the caller is a current member). Amounts are integer cents; files travel as -`{ fileName, contentType, base64 }`. - -```ts -type FeeDetail = { - year: string // "2026/27" - status: FeeStatus // the same value as member.fee - amountCents: number - dueOn: string - upload: { - fileName: string - size: number - uploadedAt: string - transferDate: string - amountCents: number - } | null - rejection: { reason: string; by: string; at: string } | null // until the member uploads again - payment: { - paidOn: string - method: "transfer" | "cash" - amountCents: number - transferDate: string | null - } | null - lastReminder: string | null -} - -type Receipt = { - id: string - number: string // "2026/042", never reused - year: string // fee year - person: { id: PersonId; name: string } - memberNumber: string - amountCents: number - issuedOn: string - paidOn: string - method: "transfer" | "cash" - transferDate: string | null - status: "toSign" | "signed" | "sent" | "failed" | "voided" - signer: "president" | "vicePresident" // the Vice-president signs the President's own - signedBy: string | null - signedOn: string | null - sentAt: string | null - sentTo: string | null - failure: string | null - voided: { reason: string; by: string; on: string } | null -} - -type Fees = { current: FeeDetail | null; receipts: Receipt[] } // newest first, voided included + | { state: "none" } ``` -| Procedure | Kind | Input | Output | Needs | -| ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------- | -| `people.fees.review` | query | `{ personId }` | `{ person, memberNumber, m365Email, fee: FeeDetail, rejections, nextReceipt }` | `people:fees:write` | -| `people.fees.upload` | query | `{ personId }` | file: the uploaded transfer receipt | `people:fees:write` | -| `people.fees.approve` | mutation | `{ personId }` | `{ receipt }` (the number issued) | `people:fees:write` | -| `people.fees.reject` | mutation | `{ personId, reason }` | `void` (fee back to Due, or Unpaid after the deadline) | `people:fees:write` | -| `people.fees.markPaid` | mutation | `{ personId, paidOn, amountCents, method, note }` | `{ receipt }` | `people:fees:write` | -| `people.fees.remind` | mutation | `{ personId: PersonId \| null }` | `{ sent, skipped }` (all: Due and Unpaid, skips reminded < 3 days) | `people:fees:write` | -| `people.receipts.list` | query | `{ year: string \| null }` (null: current fee year) | `{ year, years, viewerSigner, officers: { president, vicePresident }, receipts }` | `people:members:read` | -| `people.receipts.download` | query | `{ receiptId }` | file: the signed copy once there is one, else the generated PDF | `people:members:read` | -| `people.receipts.prepareSignatures` | mutation | `{ receiptIds: string[] }` (1–10 distinct IDs) | `{ receiptId, requestId: UUID, title }[]` | `people:receipts:sign` + `esign:sign` | -| `people.receipts.toSign` | mutation | — | file: a ZIP of the receipts waiting for the caller's signature | `people:receipts:sign` | -| `people.receipts.previewSigned` | mutation | `FormData` (`files`: up to 100 PDF or .p7m, 10 MB each, 50 MB in all, names up to 200 characters) | `{ uploadId, files: { fileId, fileName, size, receiptId, signature: { valid, signer } }[] }` | `people:receipts:sign` | -| `people.receipts.sendSigned` | mutation | `{ uploadId, assignments: Record }` | `{ sent, failed }` | `people:receipts:sign` | -| `people.receipts.retry` | mutation | `{ receiptId }` | `void` | `people:fees:write` | -| `people.receipts.void` | mutation | `{ receiptId, reason }` | `void` | `people:fees:write` | -| `people.me.uploadTransferReceipt` | mutation | `FormData` (`file` PDF/JPG/PNG ≤ 10 MB, `transferDate`, `amountCents`) | `void` (fee → Receipt uploaded; `CONFLICT` unless Due or Unpaid) | `people:self` | -| `people.me.receipt` | query | `{ receiptId }` | file: one of the caller's own signed receipts | `people:self` | - -Backend rules for fees and receipts: - -- **Officers**: the association's President and Vice-president are configuration on the backend (or a record field). - `viewerSigner` is the caller's office, and only when they also hold `people:receipts:sign`. Every receipt's `signer` - is `president`, except the President's own (`vicePresident`). `toSign`, `previewSigned` and `sendSigned` act only on - receipts whose `signer` is the caller's office. -- **CIE preparation**: `prepareSignatures` requires `admin:access`, both signing permissions and the actual officer - on the linked, confirmed People record. Validate the whole batch before creating anything: 1–10 distinct receipts, - all `toSign`, all assigned to that officer. Return exactly those receipt IDs with distinct request IDs; an incomplete, duplicate or unrelated response is rejected. Resolve officer IdP subjects on the backend; the browser never chooses - a signer, PDF, placement, recipient or output drive. Create or reuse one active eSign request per receipt/version - under a uniqueness constraint; retries return the same requests. Use the issued PDF with its hash and fixed signature - placement, the caller's office, and no separate eSign delivery (the receipt pipeline owns the email). -- **eSign provenance**: for requests created by this bridge, `esign.requests.list/get` return - `request.peopleReceipt: { receiptId, number, year }` (list: each request has the field). Other requests omit it or - return null; the dashboard accepts older responses without it. The title is `Receipt {number} · {member name}`. - The association is backend-owned and cannot be supplied through generic eSign request creation. Signatures shows - the receipt reference and links to `/dashboard/people/receipts?year={year}&q={number}` only for register readers. -- **Signing later from eSign**: jobs, manual signature uploads and completion recheck `people:receipts:sign`, the - confirmed officer and current assignment/version, as well as `esign:sign`. Generic eSign permissions alone never - authorize a receipt signature. `myTurn` reflects this eligibility. Reject placement changes for receipt requests: - their issued signature box is fixed, including batch signing. Receipt requests cannot be generically deleted or - cancelled; voiding through People owns invalidation and audit retention. eSign decline/failure leaves the receipt - `toSign`; preparation reuses an active request or creates a replacement for the same receipt/version. -- **CIE completion**: bind the receipt to its eSign request persistently. The eSign completion transaction verifies - the signature, officer, current receipt/version and `toSign` status, stores the signed PDF or `.p7m`, writes Activity and queues - the receipt email exactly once. Cancellation or failure leaves the receipt `toSign` for retry or manual upload; - voiding invalidates active eSign requests/jobs. A manual upload also completes or cancels the linked request/jobs. - Use concurrency guards so an upload and CIE completion cannot replace each other's file or send twice. Completion - and delivery must work with the browser closed, including requests signed later from Signatures › To sign. A - receipt's active eSign request is system-owned: generic request deletion must not break receipt retention/audit. -- **Numbering**: per fee year, `YYYY/NNN` from the year's first calendar year; issued atomically, never reused, voided - ones included. -- **Signatures**: verify each file for real (PAdES in the PDF or a CAdES `.p7m`), that the certificate is the officer's, - and that the signed document is the receipt it is matched to. Refuse the whole send when any assignment points at an - unsigned file, a receipt that isn't `toSign`, another officer's receipt, or the same receipt twice. -- **Email**: send the signed receipt to the member's Microsoft 365 address; on failure keep the receipt `failed` with - the mail server's reason until Retry. -- **Reminders**: automatic ones go out 14 and 3 days before the deadline to members whose fee is Due; manual ones - follow `people.fees.remind`. Each sets `lastReminder` and writes an activity line. -- **Uploads**: check the content, not the extension (PDF, JPEG or PNG; PDF or CMS for signed files), and the size. -- **Activity**: upload, approval, rejection (with the reason), Mark as paid (with the note), issue, signature, email, - failure, retry, void and reminders each add a line to the person's activity. -- **Members** download only their own receipts, and only once signed (`signed`, `sent` or `failed`). - ## Permissions -New IdP permissions (add to `src/server/permissions.ts`): `people:self`, `people:read`, `people:write`, -`people:members:read`, `people:members:write`, `people:appoint`, and for fees `people:fees:write` and -`people:receipts:sign`. 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. +IdP permissions (in `src/server/permissions.ts`): `people:self`, `people:read`, `people:write` and `people:appoint`. +`people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` are gone with Soci, fees +and receipts (#138, deleted from the IdP in PoliNetworkOrg/auth#77). 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.get` and team +writes to them. ## Backend rules @@ -397,15 +176,6 @@ The dashboard checks these too, but the backend owns them (from the security rev top of the permission in the table. `people:self` alone reaches only My area, Capo Admin and the caller's own team. For team procedures, the general `people:read`/`people:write` branch requires `admin:access`; otherwise require `people:self` and an active, confirmed Lead role over the requested team. A stray general permission without `admin:access` never widens team access. -- **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. Approving records the decision; the Telegram bot, not the dashboard, makes the person an admin of - the group. - **`people.teams.list`**: a Lead without `people:read` gets contacts (phone, personal email) only for the team's current members; former members come back without them. - **`people.teams.endMembership`**: ending a lead's membership needs `people:appoint`, like appointing one. A team has at most one Lead: @@ -413,35 +183,14 @@ The dashboard checks these too, but the backend owns them (from the security rev - **`people.admins.end`**: ending a Capo Admin's admin role also ends the scope, so it needs `people:appoint` too. - **`people.me.acceptInvite`**: the token alone identifies the invited record (store it hashed); it fails while the invite is revoked, expired or already used, or when the caller already has a linked record. -- **`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, 50 MB decompressed, at most 1,000 ZIP entries and 5,000 response rows, 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`. + 10 keys of 40 characters, values of 200, lists of 20). Telegram usernames are empty or 5–32 letters, digits and + underscores, with an optional leading `@`. -Security review verification (PR #97): scope checks, unconfirmed matches, CSV escaping, ending Lead roles and member -redaction are exercised in `tests/people-access.test.mjs`; bounded input and binary/renamed/oversized imports in +Security review verification (PR #97): scope checks, the Capo Admin area and ending Lead roles are exercised in +`tests/people-access.test.mjs`; bounded input and parsing of the current backend's legacy fields in `tests/people-input.test.mjs`. General People middleware starts from `adminMiddleware`; team exceptions follow the -rule above. BFF workbook checks inspect the ZIP directory; the backend must enforce actual decompression and parsed -row limits, not trust ZIP size declarations. The backend is pending, so these are requirements, not a production audit. - -Input limits also cover opaque IDs (128 chars), review resolutions (64), emails (254), academic years (`YYYY/YY`) -requested roles (at most 3) and real ISO calendar dates (invalid days are rejected). Reject oversize requests before parsing; imports are bound to the caller, expire, -and may only commit decisions for rows/pairs in that caller's preview. - -`people.members.create` admits an unregistered person only: refuse a record that already has a membership, -including a former membership. It must never overwrite a member number, account or fee history. Its initial fee is -`due` or `paid`; `receipt` requires an actual uploaded transfer receipt and cannot be selected at admission. `paid` issues a receipt, so it -also needs `people:fees:write`; without it the dashboard admits with the fee due. - -### Census group changes +rule above. -Team roles deselected in `people.me.answerCensus` end immediately. Telegram admin rights require HR to remove them -and the bot to confirm the result. The backend persists each deselected group's chat ID as pending HR work, adds a -`admin-removal` discrepancy to the record, and records the request in Activity. My area shows the pending state. -Reading the record or People list reconciles pending removals with the bot's current group assignments; confirmed -removals clear their discrepancy and add a completion event. A leaving answer requests removal from every group. +Input limits also cover opaque IDs (128 chars), emails (254) and real ISO calendar dates +(invalid days are rejected). Reject oversize requests before parsing. diff --git a/docs/people/decisions.md b/docs/people/decisions.md index bee61ae2..f0f800ac 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -3,6 +3,15 @@ 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. +> [!IMPORTANT] +> **Cleanup (#138).** Soci (the member register), fees and receipts, and the census are now **unconnected UI +> mockups**: their pages render static sample data from `src/features/people/mockup-data.ts`, make no backend call, +> disable every action and show a banner. Soci, fees and receipts will be rebuilt from scratch on Microsoft 365 members +> in Phase 2 (#144), the census in Phase 3 (#145). Record matching (the Linking review queue, merging, My area's "Is +> this you?", Ask to be added) and Telegram group tracking (group columns, join requests) are removed. The permissions +> `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign` are gone; the mockups +> open with `people:read`. Sections below that describe these features describe the mockup or the removed design. + ## Decisions made with the owner 1. **Dashboard only.** `@polinetwork/backend` 1.4.0 has no People procedures (only `auth`, `azure`, `groups`, `me`, @@ -52,8 +61,7 @@ Known prototype defects to not reproduce: 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. Receipts does the - same with the receipts waiting for the signed-in officer. +- Linking and Receipts showed a pending count in the panel; both went with the review queue and receipt signing (#138). ## Data and display @@ -117,19 +125,17 @@ Known prototype defects to not reproduce: ## 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 | - -Fees add `people:fees:write` (the Direttivo, or whoever handles payments) and `people:receipts:sign` (the President -and the Vice-president, who sign only as the officer on their own record). Direct CIE signing also needs `esign:sign`; -it reuses PoliNetwork eSign with a durable backend receipt/request association. ZIP download and signed-file upload -remain an option. - -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. +| Viewer | Permissions (IdP) | Sees | +| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------------- | +| Direttivo | `admin:access`, `people:read`, `people:write`, `people:appoint`, `people:self` | Everything | +| HR | `admin:access`, `people:read`, `people:write`, `people:self` | All People sections | +| 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 | + +The fee permissions (`people:fees:write`, `people:receipts:sign`) and the eSign receipt bridge were removed in #138; +the Member register, Receipts and Census mockups open with `people:read`. + +Contacts (phone, personal email) need `people:read`, or Capo Admin scope / Team Lead role over that person. HR notes +need `people:read`. The backend enforces all of this again; the dashboard only hides. diff --git a/docs/people/fees.md b/docs/people/fees.md index 0bf84f7b..01c30490 100644 --- a/docs/people/fees.md +++ b/docs/people/fees.md @@ -1,5 +1,11 @@ # People area: fees and receipts +> [!IMPORTANT] +> **This describes an unconnected mockup.** Since #138, People › Member register, People › Receipts and My area's +> fee card render static sample data, make no backend call and disable every action. The procedures, permissions +> (`people:fees:write`, `people:receipts:sign`) and the eSign receipt bridge below are removed. Soci, fees and receipts +> will be rebuilt from scratch on Microsoft 365 members in Phase 2 (#144); this file stays as the design reference. + How a member renews and gets a receipt. Shapes and procedures are in [`contract.md`](contract.md) ("Fees and receipts"); `src/features/people/people.contract.ts` stays the source of truth. The design canvas (desktop and mobile, light and dark, every state) is , private to the owner; like the diff --git a/docs/people/permissions.md b/docs/people/permissions.md index efe5aa67..111b00ec 100644 --- a/docs/people/permissions.md +++ b/docs/people/permissions.md @@ -2,23 +2,19 @@ Permissions are additive and never imply one another. Administrative pages/actions below also need `admin:access`. Only My area, an active Capo Admin's scope and a Team Lead's own teams work with `people:self` alone. Record roles -count only after account linking is confirmed. An officer title alone gives no extra permission. “Member” means socio; -“team member” means a volunteer in a team. Fees/receipts apply in PR #100. +count only after account linking is confirmed. “Team member” means a volunteer in a team. `people:members:read`, +`people:members:write`, `people:fees:write` and `people:receipts:sign` were removed in #138. -| Viewer / extra permissions | Pages | Actions | -| ----------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Volunteer: `people:self`, P-0115 | My area, Sign-in & security | Own census, correction request, photo-consent revocation; own fee upload and signed receipts | -| Team Lead: `people:self`, P-0034 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | -| Capo Admin: `people:self`, P-0098 | My area + Telegram › Capo Admin | Approve/decline requests only for own groups; contact scoped admins | -| `people:read` | People overview, person records, Admins, Teams, Census, Linking | Read contacts/notes/census; no editing, linking, imports or appointments; member details hidden | -| `people:members:read` | Member register, Receipts (#100) | Read register, download receipts; full records require `people:read` | -| `people:members:read,people:members:write` | Same | Admit members, change status/number, export; no fee review or signing | -| `people:members:read,people:fees:write` | Register, Receipts (#100) | Review transfer receipts, mark paid, remind, retry delivery, void one receipt | -| `people:members:read,people:receipts:sign,esign:sign` | Receipts + Signatures (#100) | Only the officer on the confirmed record signs assigned receipts, with the shared CIE popup or manual files | -| President / Vice-president: P-0007 / P-0098 | As granted by permissions | President signs ordinary receipts; Vice-president signs the President's receipt. No officer role: no signing action | -| Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations; receipt signing still limited to own office | +| Viewer / extra permissions | Pages | Actions | +| ---------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Volunteer: `people:self`, P-0115 | My area, Sign-in & security | Read own record and roles | +| Team Lead: `people:self`, P-0034 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access | +| Capo Admin: `people:self`, P-0098 | My area + Telegram › Capo Admin | Contact the admins who have them as Capo Admin of reference | +| `people:read` | People overview, person records, Admins, Teams, Linking | Read contacts and notes; no editing, linking or appointments | +| `people:read` (mockups) | Member register, Receipts, Census; the fee card in My area | None: unconnected mockups with sample data (#138), to be rebuilt in Phase 2 (#144) and Phase 3 (#145) | +| Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations | -`people:write` enables profile, admin-role, team, census and linking mutations on readable pages; +`people:write` enables profile, admin-role, team and linking mutations on readable pages; `people:appoint` enables Capo/Lead appointments and ending those roles. Without their read prerequisites, write permissions alone do not open a page. Buttons and row links follow these same rules; one available action is direct. diff --git a/docs/people/plan.md b/docs/people/plan.md index 28af65d0..55da677a 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -198,3 +198,13 @@ Newest last. One line per commit or session: date, what was done, what is next. `people.receipts.prepareSignatures` contract and adapter, both signing permissions, and regression tests for officer restrictions, loader refresh and merged navigation. The People backend bridge remains pending, so agent mode reports that requirement instead of simulating CIE success. + +- 2026-10-29: cleanup (#138), deployed before PoliNetworkOrg/backend#100. Member register, Receipts and Census (and + My area's fee card) are unconnected mockups: static sample data from `mockup-data.ts`, no loaders or server + functions, disabled actions and a shared `MockupBanner` linking Phase 2 (#144) or Phase 3 (#145), gated by + `people:read`. Removed: the members, fees, receipts and census server functions, contract entries and agent-mode + data; the eSign receipt bridge (`peopleReceipt`); Telegram group columns and Capo Admin join requests; the Linking + review queue and My area's first-sign-in match, Ask to be added, census, correction and photo-consent revocation; the permissions + `people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign`. The schemas strip the + fields the current backend still sends until its own cleanup deploys. + The backend counterpart is PoliNetworkOrg/backend#104, which deploys after this. diff --git a/docs/people/verification.md b/docs/people/verification.md index d817c2cf..cd2f5400 100644 --- a/docs/people/verification.md +++ b/docs/people/verification.md @@ -1,5 +1,10 @@ # People merge audit — 18 October 2026 +> [!NOTE] +> Historical record. Since #138 the member register, fees, receipts and census are unconnected mockups, and record +> matching, Telegram group tracking and the permissions `people:members:*`, `people:fees:write` and +> `people:receipts:sign` are removed. The checks of those features below no longer apply. + ## Base PR #97 Used `PORT=10870/10871 AGENT_MODE=true pnpm dev`, with the permission lists and records in diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index eca18600..c4a6066d 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -181,12 +181,13 @@ const people: Service = { permission: "people:read", searchPlaceholder: "Search by name, email or Telegram…", }, + // Member register, Receipts and Census are unconnected mockups (Phase 2, #144; Phase 3, #145). { id: "members", title: "Member register", icon: IdCard, path: "/dashboard/people/members", - permission: "people:members:read", + permission: "people:read", searchPlaceholder: "Search by name, number or email…", }, { @@ -194,7 +195,7 @@ const people: Service = { title: "Receipts", icon: Receipt, path: "/dashboard/people/receipts", - permission: "people:members:read", + permission: "people:read", searchPlaceholder: "Search by name or receipt number…", }, { @@ -203,7 +204,7 @@ const people: Service = { icon: UserCog, path: "/dashboard/people/admins", permission: "people:read", - searchPlaceholder: "Search by name, course or group…", + searchPlaceholder: "Search by name, course or username…", }, { id: "teams", @@ -704,8 +705,6 @@ export function documentTitle(match: PageMatch): string { export type PendingCounts = { reports: Promise signatures: Promise - receipts: Promise - linking: Promise } const NO_COUNT: Promise = Promise.resolve(null) @@ -728,7 +727,5 @@ export function sectionPending( ): Promise { if (serviceId === "reports" && sectionId === "open") return pending.reports if (serviceId === "esign" && sectionId === "inbox") return pending.signatures - if (serviceId === "people" && sectionId === "linking") return pending.linking - if (serviceId === "people" && sectionId === "receipts") return pending.receipts return NO_COUNT } diff --git a/src/features/esign/batch-page.tsx b/src/features/esign/batch-page.tsx index 8bf64093..d28adabf 100644 --- a/src/features/esign/batch-page.tsx +++ b/src/features/esign/batch-page.tsx @@ -6,7 +6,7 @@ import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from import { buttonMotion, EmptyState, IconButton, InlineAlert, inlineLink } from "@/components/primitives" import { appToast, PageBar, PageContent } from "@/components/shell" import { Button } from "@/components/ui/button" -import type { EsignPdfInfo, EsignRequest, EsignRequestDetail } from "@/lib/api/types" +import type { EsignPdfInfo, EsignRequestDetail } from "@/lib/api/types" import { cn } from "@/lib/utils" import { computerOnly, EsignActionCard } from "./action-card" @@ -21,10 +21,7 @@ import { useKnownBrowser, useSigningDevice } from "./signing-device" import { useHostedSignWithCie } from "./signing-host" import { useReloadWhile } from "./use-reload-while" -type Item = Pick & { - request: EsignRequest - pdf: EsignPdfInfo | null -} +type Item = Pick & { pdf: EsignPdfInfo | null } const BACK = { label: "documents to sign", link: { to: "/dashboard/esign/inbox" } } as const @@ -119,8 +116,7 @@ export function EsignBatchPage({ return { requestId: item.request.id, title: item.request.title, - // A receipt's box is fixed: its placement is part of the receipt. - places: mine?.signaturesDone === 0 && !item.request.peopleReceipt ? moved.get(item.request.id) : undefined, + places: mine?.signaturesDone === 0 ? moved.get(item.request.id) : undefined, } }), handOff, @@ -182,8 +178,8 @@ export function EsignBatchPage({ const signer = active.request.signers.find((s) => s.sub === me && s.status === "pending") const places = moved.get(active.request.id) ?? signer?.places ?? [] - // Phones review only, and a receipt's box is fixed: the box stays where it is. - const editable = signer?.signaturesDone === 0 && !active.request.peopleReceipt && device !== "phone" + // Phones review only: the box stays where it is. + const editable = signer?.signaturesDone === 0 && device !== "phone" const out = leftOut.has(active.request.id) const lockedBy = active.signingNow && active.signingNow.sub !== me ? active.signingNow : null const signs = device !== "phone" diff --git a/src/features/esign/esign.functions.ts b/src/features/esign/esign.functions.ts index 7b4f69bb..4a3d021d 100644 --- a/src/features/esign/esign.functions.ts +++ b/src/features/esign/esign.functions.ts @@ -5,7 +5,6 @@ import { esignReadMiddleware, esignSignWriteMiddleware } from "@/server/auth.mid import type { BackendClient } from "@/server/backend.server" import { MAX_BATCH } from "./agent" -import { withReceiptSource } from "./receipt-source" /** The audit payloads the backend writes; anything else is dropped. */ const eventData = z @@ -40,7 +39,7 @@ export const getEsignHome = createServerFn() context.backend.esign.config.query(), context.backend.esign.requests.list.query({ scope: data.scope }), ]) - return { config, requests: requests.map(withReceiptSource) } + return { config, requests } }) /** How many requests wait for the caller's signature, for the navigation badge. */ @@ -58,7 +57,7 @@ async function requestDetail(backend: BackendClient, id: string) { const parsed = eventData.safeParse(event.data ?? {}) return { ...event, data: parsed.success ? parsed.data : {} } }) - return { ...detail, request: withReceiptSource(detail.request), events, pdf: document?.pdf ?? null } + return { ...detail, events, pdf: document?.pdf ?? null } } export const getEsignRequest = createServerFn() @@ -85,9 +84,7 @@ export const getEsignBatch = createServerFn() .validator(z.object({ first: z.uuid().optional() })) .handler(async ({ data, context }) => { const inbox = await context.backend.esign.requests.list.query({ scope: "inbox" }) - const mine = inbox - .map(withReceiptSource) - .filter((r) => r.myTurn && (!r.peopleReceipt || context.permissions.includes("people:receipts:sign"))) + const mine = inbox.filter((r) => r.myTurn) const waiting = [...mine.filter((r) => r.id === data.first), ...mine.filter((r) => r.id !== data.first)] const items = await Promise.all(waiting.slice(0, MAX_BATCH).map((r) => requestDetail(context.backend, r.id))) return { items, more: Math.max(waiting.length - MAX_BATCH, 0), me: context.user.sub } diff --git a/src/features/esign/receipt-request-link.tsx b/src/features/esign/receipt-request-link.tsx deleted file mode 100644 index 366112ea..00000000 --- a/src/features/esign/receipt-request-link.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Link } from "@tanstack/react-router" - -import { useCan, useHasAdminAccess } from "@/components/shell" -import type { ReceiptRequestSource } from "@/features/people/people.contract" - -/** The receipt remains identifiable to signers who cannot open the member register. */ -export function ReceiptRequestLink({ source }: { source: ReceiptRequestSource }) { - const canRead = useCan("people:members:read") - const hasAdminAccess = useHasAdminAccess() - const label = `Fee receipt ${source.number} · ${source.year}` - return canRead && hasAdminAccess ? ( - - {label} - - ) : ( - {label} - ) -} diff --git a/src/features/esign/receipt-source.ts b/src/features/esign/receipt-source.ts deleted file mode 100644 index 48f3a4f4..00000000 --- a/src/features/esign/receipt-source.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { receiptRequestSourceSchema } from "../people/people.contract" - -/** Older eSign responses have no People association; an advertised association must satisfy the bridge contract. */ -export function withReceiptSource(request: T) { - const source = "peopleReceipt" in request ? request.peopleReceipt : null - return { ...request, peopleReceipt: receiptRequestSourceSchema.nullish().parse(source) ?? null } -} diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index b0eff486..07b45bc7 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -22,7 +22,7 @@ import { import { appToast, PageBar, PageContent, useCan } from "@/components/shell" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" -import type { EsignDrive, EsignPdfInfo, EsignRequest, EsignRequestDetail } from "@/lib/api/types" +import type { EsignDrive, EsignPdfInfo, EsignRequestDetail } from "@/lib/api/types" import { errorHasCode } from "@/lib/errors" import { formatDateTime } from "@/lib/format" import { cn } from "@/lib/utils" @@ -33,7 +33,6 @@ import { landingFor } from "./landing" import { type PdfFocus, PdfViewer, usePdfBytes } from "./pdf-viewer" import { kindsLabel, type Place, placeAt, placesLeft } from "./places" import { MoveBoxButton, PlacingCancel, placingStatus } from "./placing" -import { ReceiptRequestLink } from "./receipt-request-link" import { isDelivering, openPoint, type RequestCard, requestCardState } from "./request-card" import { SignerStatusBadge, YouChip } from "./request-status" import { cancelEsignRequest, deleteEsignRequest, retryEsignDelivery } from "./requests.functions" @@ -59,8 +58,7 @@ type Ghosts = { afterDialog: boolean } -type Detail = Omit & { - request: EsignRequestDetail["request"] & Pick +type Detail = Omit & { events: (Omit & { data: EventData })[] pdf: EsignPdfInfo | null me: string @@ -88,8 +86,6 @@ function eventText(event: Detail["events"][number], names: Map) return `${who} cancelled the request` case "declined": return `${who} declined` - case "people_invalidated": - return `${who} voided the receipt` default: return event.type } @@ -113,7 +109,6 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { const router = useRouter() const navigate = useNavigate() const canManage = useCan("esign:requests:manage") - const canSignReceipts = useCan("people:receipts:sign") const device = useSigningDevice() const knownBrowser = useKnownBrowser() const contentFn = useServerFn(getEsignDocumentContent) @@ -163,11 +158,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { }, [currentDocumentId, contentFn]) const mySigner = request.signers.find((s) => s.sub === me && s.status === "pending") - // A fee receipt belongs to People: its box is fixed, it is signed only with the receipt permission, and it is never - // cancelled, deleted or delivered from here. - const systemOwned = !!request.peopleReceipt - const receiptLocked = systemOwned && !canSignReceipts - const myTurn = request.myTurn && !receiptLocked && mySigner !== undefined + const myTurn = request.myTurn && mySigner !== undefined // Order numbers only where the order holds, as in New request. const numbered = request.ordered && request.signers.length > 1 const numberOf = (signerId: string) => @@ -183,8 +174,8 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { setMyPlaces(savedPlaces) } const myDone = mySigner?.signaturesDone ?? 0 - // Phones review only, and a receipt's box is fixed: the box stays where it is. - const editable = myTurn && myDone === 0 && !systemOwned && device !== "phone" + // Phones review only: the box stays where it is. + const editable = myTurn && myDone === 0 && device !== "phone" /** The place waiting for a click on its new spot; placing ends by itself once the boxes are fixed. */ const [placing, setPlacing] = useState(null) const placingAt = editable ? placing : null @@ -210,25 +201,12 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { const card = requestCardState(detail, { me, - canManage: canManage && !systemOwned, + canManage, device, knownBrowser, now: new Date(), places: myTurn ? myPlaces : undefined, }) - if (systemOwned) { - // People emails the signed receipt to its member; eSign doesn't deliver it. - card.delivery = [] - if (receiptLocked && card.primary?.kind === "sign") { - card.primary = null - card.signTogether = null - card.muted = null - card.lock = null - card.body = "Signing fee receipts needs the receipt-signing permission." - } else if (card.primary?.kind === "sign" && myDone === 0) { - card.body = "The receipt's signature box is fixed. Sign with CIE." - } - } const [ghosts, setGhosts] = useState(() => { const landed = landing && request.signers.find((s) => s.sub === me && s.signaturesDone > 0) @@ -303,9 +281,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { } const signing = useSignWithCie({ - items: [ - { requestId: request.id, title: request.title, places: myDone === 0 && !systemOwned ? myPlaces : undefined }, - ], + items: [{ requestId: request.id, title: request.title, places: myDone === 0 ? myPlaces : undefined }], handOff, onSigned: released, finalFocus: () => bodyRef.current, @@ -344,7 +320,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { const deliveryPlanned = request.output !== null || (request.email?.to.length ?? 0) > 0 const drive = detail.config.drives.find((d) => d.id === request.output?.driveId)?.label ?? "OneDrive" // A request in progress is cancelled first; a pending delivery still needs the request. - const canDelete = !inProgress && request.delivery?.status !== "pending" && canManage && !systemOwned + const canDelete = !inProgress && request.delivery?.status !== "pending" && canManage const pages = pdf?.pages ?? [] const soleSigner = request.signers.length === 1 && request.signers[0].sub === me const details = useScrolls() @@ -440,12 +416,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) {
- {card.badge.label} - {request.peopleReceipt && } - - } + badge={{card.badge.label}} meta={card.meta} body={card.body} bodyId={bodyId} @@ -653,7 +624,7 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { )} - {inProgress && deliveryPlanned && !systemOwned && ( + {inProgress && deliveryPlanned && (
    {request.output && ( diff --git a/src/features/esign/requests-page.tsx b/src/features/esign/requests-page.tsx index aa029d22..2fffd8e0 100644 --- a/src/features/esign/requests-page.tsx +++ b/src/features/esign/requests-page.tsx @@ -12,7 +12,6 @@ import { cn } from "@/lib/utils" import { computerOnly, phoneOnly } from "./action-card" import { MAX_BATCH } from "./agent" -import { ReceiptRequestLink } from "./receipt-request-link" import { RequestStatusBadge, type StatusFilter, signedProgress } from "./request-status" import { useSigningDevice } from "./signing-device" @@ -61,11 +60,7 @@ const columns: DataTableColumn[] = [ cell: (r) => ( {r.title} - {r.peopleReceipt ? ( - - ) : ( - Requested by {r.createdByName} - )} + Requested by {r.createdByName} ), }, @@ -118,7 +113,6 @@ export function EsignRequestsPage({ onStatusChange?: (status: StatusFilter) => void }) { const canManage = useCan("esign:requests:manage") - const canSignReceipts = useCan("people:receipts:sign") // Until hydration a Mac may be an iPad: both the button and the note render, and CSS shows one. const device = useSigningDevice() const navigate = useNavigate() @@ -139,7 +133,7 @@ export function EsignRequestsPage({ [requests, status, deferred] ) const narrowed = visible.length !== requests.length - const waiting = inbox ? requests.filter((r) => r.myTurn && (!r.peopleReceipt || canSignReceipts)).length : 0 + const waiting = inbox ? requests.filter((r) => r.myTurn).length : 0 const description = inbox ? requests.length diff --git a/src/features/people/admin-dialogs.tsx b/src/features/people/admin-dialogs.tsx index 542630f3..0942703d 100644 --- a/src/features/people/admin-dialogs.tsx +++ b/src/features/people/admin-dialogs.tsx @@ -12,7 +12,6 @@ import { Spinner, } from "@/components/primitives" import { Textarea } from "@/components/ui/textarea" -import { pluralize } from "@/lib/format" import { cn } from "@/lib/utils" import { setCapoScope } from "./people-appoint.functions" @@ -25,9 +24,9 @@ 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 } +export type AdminTarget = { personId: string; name: string } -/** Ends an admin role with a reason; the bot removes their rights in every group they administer. */ +/** Ends an admin role with a reason; the bot removes their admin rights. */ export function EndAdminDialog({ open, onOpenChange, @@ -64,7 +63,7 @@ export function EndAdminDialog({ open={open} onOpenChange={onOpenChange} title="End admin role?" - description={`${target.name} stops being an admin of ${pluralize(target.groups, "group")}. The bot removes their admin rights there; the record keeps the history.`} + description={`${target.name} stops being an admin. The bot removes their admin rights; the record keeps the history.`} noun="change" dirty={reason !== null || details.trim() !== ""} submitLabel="End admin role" diff --git a/src/features/people/admins-page.tsx b/src/features/people/admins-page.tsx index f3883864..1e3f4b7f 100644 --- a/src/features/people/admins-page.tsx +++ b/src/features/people/admins-page.tsx @@ -29,24 +29,14 @@ import { EndAdminDialog, EndCapoDialog, } from "./admin-dialogs" -import { - CENSUS_ANSWER, - censusEntry, - currentYear, - formatDay, - isMember, - NO_ANSWER, - personName, - telegramUrl, - yearOf, -} from "./people-format" -import { ActionMenu, PersonCell, StateBadge, TruncatedChip } from "./people-ui" +import { formatDay, personName, telegramUrl, yearOf } from "./people-format" +import { ActionMenu, PersonCell, TruncatedChip } from "./people-ui" import { type AdminRole, CAMPUSES, type Campus, type PersonRow, YEARS } from "./people.contract" type Admin = PersonRow & { admin: AdminRole } type Scope = "active" | "former" type Year = (typeof YEARS)[number] -type Quick = "noGroups" | "availableAsCapo" | "declaredMember" | "noCensus" +type Quick = "availableAsCapo" type AdminDialog = { kind: "end" | "capoRef" | "capoScope" | "endCapo"; admin: Admin } function isAdmin(person: PersonRow): person is Admin { @@ -54,41 +44,19 @@ function isAdmin(person: PersonRow): person is Admin { } const QUICK = { - noGroups: { label: "No groups", test: (person: Admin) => person.admin.groups.length === 0 }, availableAsCapo: { label: "Available as Capo Admin", test: (person: Admin) => person.admin.availableAsCapo }, - declaredMember: { - label: "Declared member but not registered", - test: (person: Admin) => censusEntry(person, currentYear())?.declaredMember === true && !isMember(person), - }, - noCensus: { - label: "No census answer", - test: (person: Admin) => { - const entry = censusEntry(person, currentYear()) - return entry !== undefined && entry.answer === null - }, - }, } satisfies Record boolean }> -const QUICK_IDS = ["noGroups", "availableAsCapo", "declaredMember", "noCensus"] satisfies Quick[] +const QUICK_IDS = ["availableAsCapo"] satisfies Quick[] function matchesQuery(person: Admin, query: string) { if (!query) return true - return [ - personName(person), - person.profile.course, - person.profile.telegramUsername ?? "", - ...person.admin.groups.map((group) => group.title), - ].some((value) => value.toLocaleLowerCase().includes(query)) -} - -function CensusCell({ person }: { person: Admin }) { - const entry = censusEntry(person, currentYear()) - if (!entry) return - return + return [personName(person), person.profile.course, person.profile.telegramUsername ?? ""].some((value) => + value.toLocaleLowerCase().includes(query) + ) } -/** The table's columns; the census and fee columns name the current academic year. */ -function tableColumns(year: string): DataTableColumn[] { +function tableColumns(): DataTableColumn[] { return [ { id: "name", @@ -104,16 +72,10 @@ function tableColumns(year: string): DataTableColumn[] { secondary={person.profile.course} muted={person.admin.status === "former"} /> - {/* Mobile cards: groups and census on one labelled line, then the Capo Admin scope. */} + {/* Mobile cards: the admin role on one line, then the Capo Admin scope. */} - - {person.admin.status !== "active" - ? "Former admin" - : person.admin.groups.length - ? pluralize(person.admin.groups.length, "group") - : "No groups"} - · - Census + + {person.admin.status !== "active" ? "Former admin" : `Admin since ${formatDay(person.admin.since)}`} {person.admin.capoScope ? Capo Admin · {person.admin.capoScope} : null} @@ -143,42 +105,11 @@ function tableColumns(year: string): DataTableColumn[] { width: 128, priority: 5, mobile: "hidden", - cell: (person) => {formatDay(person.admin.since)}, - }, - { - id: "groups", - label: "Groups", - width: 232, - mobile: "hidden", cell: (person) => person.admin.status === "former" ? ( - Ended {formatDay(person.admin.endedOn ?? "")} - ) : person.admin.groups.length ? ( - group.title} - renderItem={(group) => } - /> - ) : ( - No groups - ), - }, - { - id: "requested", - label: "Requested", - width: 160, - priority: 6, - mobile: "hidden", - cell: (person) => - person.admin.requested.length ? ( - group.title} - renderItem={(group) => } - /> + Ended {formatDay(person.admin.endedOn ?? "")} ) : ( - + {formatDay(person.admin.since)} ), }, { @@ -235,31 +166,15 @@ function tableColumns(year: string): DataTableColumn[] { ), }, - { - id: "member", - label: "Member", - width: 88, - priority: 3, - mobile: "hidden", - cell: (person) => (isMember(person) ? "Yes" : No), - }, - { - id: "census", - label: `Census ${year}`, - width: 152, - mobile: "hidden", - cell: (person) => , - }, ] } const DEFAULT_SORT: TableSort = { column: "name", direction: "asc" } -/** People › Admins: admins of the Telegram course groups, their groups, Capo Admin and census answer. */ +/** People › Admins: admins of the Telegram course groups and their Capo Admin. */ export function AdminsPage({ people }: { people: PersonRow[] }) { const canWrite = useCan("people:write") const canAppoint = useCan("people:appoint") - const censusYear = currentYear() const admins = people.filter(isAdmin) const active = admins.filter((person) => person.admin.status === "active") @@ -324,9 +239,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { setDialogOpen(true) } - const target: AdminTarget | null = dialog - ? { personId: dialog.admin.id, name: personName(dialog.admin), groups: dialog.admin.admin.groups.length } - : null + const target: AdminTarget | null = dialog ? { personId: dialog.admin.id, name: personName(dialog.admin) } : null return ( <> @@ -337,7 +250,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { search={{ value: query, onChange: resetPage(setQuery), - placeholder: "Search by name, course or group…", + placeholder: "Search by name, course or username…", }} filters={ <> @@ -364,18 +277,15 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { onChange={resetPage(setYear)} options={YEARS.map((value) => ({ value, label: value }))} /> - {/* Quick filters scroll sideways on phones instead of stacking four rows. */} -
    - {QUICK_IDS.map((id) => ( - toggleQuick(id, pressed)} - /> - ))} -
    + {QUICK_IDS.map((id) => ( + toggleQuick(id, pressed)} + /> + ))} } count={ @@ -394,7 +304,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { person.id} rowLabel={personName} @@ -493,7 +403,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { ) } diff --git a/src/features/people/capo-page.tsx b/src/features/people/capo-page.tsx index 0bd23082..8dc015c3 100644 --- a/src/features/people/capo-page.tsx +++ b/src/features/people/capo-page.tsx @@ -1,50 +1,35 @@ -import { useServerFn } from "@tanstack/react-start" import { MessageCircle, Send, ShieldUser } from "lucide-react" import { useDeferredValue, useState } from "react" import { buttonMotion, - ChipOverflow, DataTable, type DataTableColumn, EmptyState, IconButton, PlatformGlyph, RowActions, - SectionCard, - SectionEmpty, SectionHeading, - StatusBadge, type TableSort, Unset, } from "@/components/primitives" -import { appToast, Count, PageBar, PageContent, Toolbar } from "@/components/shell" +import { Count, PageBar, PageContent, Toolbar } from "@/components/shell" import { Button } from "@/components/ui/button" import { pluralize } from "@/lib/format" -import { approveJoinRequest, declineJoinRequest } from "./people-capo.functions" import { formatDay, personName, telegramUrl, whatsappUrl } from "./people-format" -import { errorMessage, useRefreshAfter } from "./people-hooks" -import { PersonAvatar, PersonCell, TruncatedChip } from "./people-ui" +import { PersonCell } from "./people-ui" import type { CapoArea, PersonRow } from "./people.contract" -type CapoRequest = CapoArea["requests"][number] - const DEFAULT_SORT: TableSort = { column: "name", direction: "asc" } -/** Telegram › Capo Admin: the groups of the signed-in Capo Admin's course, their admins and requests to join. */ +/** Telegram › Capo Admin: the admins who have the signed-in Capo Admin as Capo of reference. */ export function CapoPage({ area }: { area: CapoArea }) { - const approve = useServerFn(approveJoinRequest) - const decline = useServerFn(declineJoinRequest) - const refresh = useRefreshAfter() const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()) const [sort, setSort] = useState(DEFAULT_SORT) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [pending, setPending] = useState(null) - // Telegram titles repeat across courses: match groups by chat ID. - const chatIds = new Set(area.groups.map((group) => group.chatId)) const filtered = area.admins.filter( (person) => @@ -57,20 +42,6 @@ export function CapoPage({ area }: { area: CapoArea }) { const sorted = filtered.toSorted((a, b) => personName(a).localeCompare(personName(b)) * sign) const rows = sorted.slice((page - 1) * pageSize, page * pageSize) - async function decide(request: CapoRequest, approved: boolean) { - setPending(request.id) - try { - await (approved ? approve : decline)({ data: { requestId: request.id } }) - } catch (caught) { - console.error(caught) - appToast.error(errorMessage(caught, approved ? "Couldn't approve the request." : "Couldn't decline the request.")) - setPending(null) - return - } - await refresh(approved ? `Approved ${request.person.name} for ${request.group.title}.` : "Request declined.") - setPending(null) - } - const columns: DataTableColumn[] = [ { id: "name", @@ -98,24 +69,6 @@ export function CapoPage({ area }: { area: CapoArea }) { cell: (person) => person.admin ? {formatDay(person.admin.since)} : , }, - { - id: "groups", - label: "Groups", - width: 200, - mobile: "full", - cell: (person) => { - const groups = person.admin?.groups.filter((group) => chatIds.has(group.chatId)) ?? [] - return groups.length ? ( - group.title} - renderItem={(group) => } - /> - ) : ( - - ) - }, - }, { id: "representative", label: "Representative", @@ -157,71 +110,11 @@ export function CapoPage({ area }: { area: CapoArea }) { return ( <> - +
    -
    - -
      - {area.groups.map((group) => ( -
    • - - - {group.title} - - {group.admins ? `${pluralize(group.admins, "admin")} · ` : ""} - {group.chatId} - - - {group.admins === 0 ? No admins : null} -
    • - ))} -
    -
    - - {area.requests.length === 0 ? ( - - ) : ( -
      - {area.requests.map((request) => ( -
    • - - {/* At least 160px for the name and date; the buttons wrap under them on phones. */} - - {request.person.name} - - {request.group.title} · asked {formatDay(request.requestedOn)} - - - - - - -
    • - ))} -
    - )} -
    -
    -
    - + person.id} @@ -303,7 +196,7 @@ export function CapoPage({ area }: { area: CapoArea }) { ) } diff --git a/src/features/people/census-answers-dialog.tsx b/src/features/people/census-answers-dialog.tsx deleted file mode 100644 index 22a8ec4b..00000000 --- a/src/features/people/census-answers-dialog.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useServerFn } from "@tanstack/react-start" -import { useMemo } from "react" - -import { FormDialog, InlineAlert, Spinner } from "@/components/primitives" - -import { useLoaded } from "./people-hooks" -import { getCensusAnswers } from "./people.functions" - -/** A census year's full answers, as the person gave them: HR and the Direttivo only. */ -export function CensusAnswersDialog({ - open, - onOpenChange, - personId, - name, - year, -}: { - open: boolean - onOpenChange: (open: boolean) => void - personId: string - name: string - year: string -}) { - const load = useServerFn(getCensusAnswers) - // Loads only while open; each reopen is a new loader, so "Close and try again" retries. - const { data: answers, failed } = useLoaded( - useMemo(() => (open ? () => load({ data: { personId, year } }) : null), [open, load, personId, year]) - ) - - return ( - onOpenChange(false)} - > - {failed ? Couldn't load the answers. Close and try again. : null} - {answers === null && !failed ? ( -

    - - Loading answers… -

    - ) : null} - {answers ? ( -
    - {answers.map((entry) => ( -
    -
    {entry.question}
    -
    {entry.answer}
    -
    - ))} -
    - ) : null} -
    - ) -} diff --git a/src/features/people/census-import.server.ts b/src/features/people/census-import.server.ts deleted file mode 100644 index 7779cc00..00000000 --- a/src/features/people/census-import.server.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** Validate before forwarding; the backend must also cap decompression and parsed rows. */ -export async function validateCensusImport(file: File) { - if (!/\.(xlsx|csv)$/i.test(file.name)) throw new Error("Choose an .xlsx or .csv file.") - if (!file.size || file.size > 10 * 1024 * 1024) throw new Error("Choose a file up to 10 MB.") - const bytes = new Uint8Array(await file.arrayBuffer()) - if (/\.csv$/i.test(file.name)) { - const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes) - if (bytes.some((byte) => byte < 32 && ![9, 10, 13].includes(byte))) throw new Error("Choose a UTF-8 CSV file.") - let quoted = false - let rows = 0 - for (let i = 0; i < text.length; i++) { - if (text[i] === '"') { - if (quoted && text[i + 1] === '"') i++ - else quoted = !quoted - } else if (text[i] === "\n" && !quoted) rows++ - } - if (quoted) throw new Error("The CSV has an unfinished quoted field.") - if (rows + (text.endsWith("\n") ? 0 : 1) > 5001) throw new Error("Import at most 5,000 responses.") - return - } - // Inspect the central directory, not bytes that happen to resemble ZIP headers inside compressed data. - const view = new DataView(bytes.buffer) - const invalid = () => new Error("Choose a valid .xlsx workbook.") - if (bytes.length < 22 || view.getUint32(0, true) !== 0x04034b50) throw invalid() - let end = bytes.length - 22 - while (end >= Math.max(0, bytes.length - 65557) && view.getUint32(end, true) !== 0x06054b50) end-- - if (end < 0 || end < bytes.length - 65557) throw invalid() - const entries = view.getUint16(end + 10, true) - const size = view.getUint32(end + 12, true) - let offset = view.getUint32(end + 16, true) - if ( - entries > 1000 || - offset + size !== end || - view.getUint16(end + 4, true) !== 0 || - view.getUint16(end + 6, true) !== 0 || - view.getUint16(end + 8, true) !== entries || - end + 22 + view.getUint16(end + 20, true) !== bytes.length - ) - throw invalid() - let expanded = 0 - const names = new Set() - for (let i = 0; i < entries; i++) { - if (offset + 46 > end || view.getUint32(offset, true) !== 0x02014b50) throw invalid() - expanded += view.getUint32(offset + 24, true) - if (expanded > 50 * 1024 * 1024) throw new Error("The expanded workbook exceeds 50 MB.") - const nameLength = view.getUint16(offset + 28, true) - const next = offset + 46 + nameLength + view.getUint16(offset + 30, true) + view.getUint16(offset + 32, true) - if (next > end) throw invalid() - names.add(new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength))) - offset = next - } - if (offset !== end || !names.has("[Content_Types].xml") || !names.has("xl/workbook.xml")) throw invalid() -} diff --git a/src/features/people/census-page.tsx b/src/features/people/census-page.tsx index 337d75ac..3ebde695 100644 --- a/src/features/people/census-page.tsx +++ b/src/features/people/census-page.tsx @@ -1,11 +1,9 @@ -import { useServerFn } from "@tanstack/react-start" import { Archive, Bell, ClipboardList, FileText, Upload } from "lucide-react" import { useDeferredValue, useState } from "react" import { buttonMotion, ChipOverflow, - ConfirmDialog, DataTable, type DataTableColumn, EmptyState, @@ -17,36 +15,28 @@ import { SegmentedControl, type TableSort, Unset, - useOpenGeneration, } from "@/components/primitives" -import { appToast, Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" +import { Count, PageBar, PageContent, Toolbar } from "@/components/shell" import { Button } from "@/components/ui/button" import { formatNumber, pluralize } from "@/lib/format" import { cn } from "@/lib/utils" -import { CensusAnswersDialog } from "./census-answers-dialog" -import { ImportWizard } from "./import-wizard" -import { closeCampaign, sendCensusReminders } from "./people-census.functions" +import { MockupBanner } from "./mockup-banner" import { - activeTeams, - CENSUS_ANSWER, - CENSUS_SOURCE, - formatDay, - isActiveAdmin, - isMember, - NO_ANSWER, - personName, - REMINDER_SKIPPED, - roleLabels, -} from "./people-format" -import { errorMessage, useRefreshAfter } from "./people-hooks" + MOCK_CAMPAIGNS, + MOCK_CENSUS_ANSWER, + MOCK_CENSUS_SOURCE, + MOCK_NO_ANSWER, + type MockCampaign, + type MockCampaignRow, +} from "./mockup-data" +import { formatDay } from "./people-format" import { PersonCell, StateBadge, TruncatedChip } from "./people-ui" -import type { Campaign, CampaignDetail, CensusEntry, PersonRow } from "./people.contract" const percent = (part: number, whole: number) => (whole ? Math.round((part / whole) * 100) : 0) /** Stacked meter: continuing (success) then leaving (warning) over the expected people. */ -function CensusMeter({ campaign, className }: { campaign: Campaign; className?: string }) { +function CensusMeter({ campaign, className }: { campaign: MockCampaign; className?: string }) { const { expected, continuing, leaving, noAnswer } = campaign return ( [] = [ +const campaignColumns: DataTableColumn[] = [ { id: "campaign", label: "Campaign", @@ -152,34 +142,33 @@ const campaignColumns: DataTableColumn[] = [ }, ] -/** People › Census: one row per yearly campaign. */ -export function CensusPage({ campaigns }: { campaigns: Campaign[] }) { +/** + * People › Census: an unconnected mockup with one row per sample yearly campaign, kept to be reworked in Phase 3 + * (#145). It reads nothing from the backend. + */ +export function CensusPage() { + const campaigns = MOCK_CAMPAIGNS const open = campaigns.find((campaign) => campaign.status === "open") return ( <> - campaign.id} - rowLabel={(campaign) => `${campaign.year} census`} - rowHref={(campaign) => `/dashboard/people/census/${encodeURIComponent(campaign.id)}`} - empty={ - - } - /> +
    + + campaign.id} + rowLabel={(campaign) => `${campaign.year} census`} + rowHref={(campaign) => `/dashboard/people/census/${encodeURIComponent(campaign.id)}`} + empty={ + + } + /> +
    ) @@ -187,38 +176,93 @@ export function CensusPage({ campaigns }: { campaigns: Campaign[] }) { type Segment = "all" | "answered" | "continuing" | "leaving" | "none" type RoleFilter = "member" | "admin" | "team" -type CampaignRow = PersonRow & { entry: CensusEntry | undefined } const SEGMENT_TEST = { all: () => true, - answered: (row: CampaignRow) => !!row.entry?.answer, - continuing: (row: CampaignRow) => row.entry?.answer === "continues", - leaving: (row: CampaignRow) => row.entry?.answer === "leaving", - none: (row: CampaignRow) => !row.entry?.answer, -} satisfies Record boolean> + answered: (row: MockCampaignRow) => row.answer !== null, + continuing: (row: MockCampaignRow) => row.answer === "continues", + leaving: (row: MockCampaignRow) => row.answer === "leaving", + none: (row: MockCampaignRow) => row.answer === null, +} satisfies Record boolean> const ROLE_TEST = { - member: (row: CampaignRow) => isMember(row), - admin: (row: CampaignRow) => isActiveAdmin(row), - team: (row: CampaignRow) => activeTeams(row).length > 0, -} satisfies Record boolean> + member: (row: MockCampaignRow) => row.roles.includes("Member"), + admin: (row: MockCampaignRow) => row.roles.includes("Admin"), + team: (row: MockCampaignRow) => row.roles.some((role) => role !== "Member" && role !== "Admin"), +} satisfies Record boolean> const DEFAULT_SORT: TableSort = { column: "name", direction: "asc" } -/** People › Census › {year}: who answered, what, and the actions of an open campaign. */ -export function CampaignPage({ detail }: { detail: CampaignDetail }) { - const { campaign } = detail - const canWrite = useCan("people:write") - const canRead = useCan("people:read") +const columns: DataTableColumn[] = [ + { + id: "name", + label: "Name", + sortable: true, + fill: true, + minWidth: 220, + mobile: "main", + cell: (row) => , + }, + { + id: "roles", + label: "Roles", + width: 200, + priority: 3, + mobile: "hidden", + cell: (row) => + row.roles.length ? ( + label} + renderItem={(label) => } + /> + ) : ( + + ), + }, + { + id: "answer", + label: "Answer", + width: 128, + mobile: "meta", + cell: (row) => , + }, + { + id: "answeredOn", + label: "Answered on", + sortable: true, + width: 136, + priority: 4, + mobile: "meta", + cell: (row) => + row.answeredOn ? {formatDay(row.answeredOn)} : , + }, + { + id: "source", + label: "Source", + width: 168, + priority: 4, + mobile: "hidden", + cell: (row) => (row.source ? MOCK_CENSUS_SOURCE[row.source] : ), + }, + { + id: "reminders", + label: "Reminders", + width: 112, + align: "end", + priority: 5, + mobile: "hidden", + cell: (row) => row.reminders, + }, +] + +/** + * People › Census › {year}: an unconnected mockup of who answered a sample campaign and what, kept to be reworked in + * Phase 3 (#145). Search, filters and sorting work on the sample rows; every action is disabled. + */ +export function CampaignPage({ detail }: { detail: { campaign: MockCampaign; people: readonly MockCampaignRow[] } }) { + const { campaign, people: all } = detail const isOpen = campaign.status === "open" - const canAct = canWrite && isOpen - const remind = useServerFn(sendCensusReminders) - const close = useServerFn(closeCampaign) - const refresh = useRefreshAfter() - const all: CampaignRow[] = detail.people.map((person) => ({ - ...person, - entry: person.census.find((entry) => entry.year === campaign.year), - })) const [segment, setSegment] = useState("all") const [role, setRole] = useState(null) @@ -227,146 +271,38 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { const [sort, setSort] = useState(DEFAULT_SORT) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [confirm, setConfirm] = useState<"remind" | "close" | null>(null) - const [confirmOpen, setConfirmOpen] = useState(false) - const [importOpen, setImportOpen] = useState(false) - const importKey = useOpenGeneration(importOpen) - const [answers, setAnswers] = useState(null) - const [answersOpen, setAnswersOpen] = useState(false) const inSegment = all.filter(SEGMENT_TEST[segment]) const filtered = inSegment.filter( (row) => (role === null || ROLE_TEST[role](row)) && - (!deferredQuery || - [personName(row), row.profile.personalEmail ?? ""].some((value) => - value.toLocaleLowerCase().includes(deferredQuery) - )) + (!deferredQuery || row.name.toLocaleLowerCase().includes(deferredQuery)) ) const sign = sort.direction === "asc" ? 1 : -1 const sorted = filtered.toSorted( (a, b) => (sort.column === "answeredOn" - ? (a.entry?.answeredOn ?? "").localeCompare(b.entry?.answeredOn ?? "") - : personName(a).localeCompare(personName(b))) * sign + ? (a.answeredOn ?? "").localeCompare(b.answeredOn ?? "") + : a.name.localeCompare(b.name)) * sign ) const rows = sorted.slice((page - 1) * pageSize, page * pageSize) const narrowed = deferredQuery !== "" || role !== null const unanswered = all.filter(SEGMENT_TEST.none).length const answered = campaign.continuing + campaign.leaving - function ask(kind: "remind" | "close") { - setConfirm(kind) - setConfirmOpen(true) - } - - async function remindOne(row: CampaignRow) { - try { - const result = await remind({ data: { campaignId: campaign.id, personId: row.id } }) - await (result.sent - ? refresh(`Reminder sent to ${personName(row)}.`) - : refresh(`No reminder sent to ${personName(row)}: ${REMINDER_SKIPPED}.`, "warning")) - } catch (caught) { - console.error(caught) - appToast.error(errorMessage(caught, "Couldn't send the reminder.")) - } - } - - const columns: DataTableColumn[] = [ - { - id: "name", - label: "Name", - sortable: true, - fill: true, - minWidth: 220, - mobile: "main", - cell: (row) => , - }, - { - id: "roles", - label: "Roles", - width: 200, - priority: 3, - mobile: "hidden", - cell: (row) => { - const labels = roleLabels(row) - return labels.length ? ( - label} - renderItem={(label) => } - /> - ) : ( - - ) - }, - }, - { - id: "answer", - label: "Answer", - width: 128, - mobile: "meta", - cell: (row) => , - }, - { - id: "answeredOn", - label: "Answered on", - sortable: true, - width: 136, - priority: 4, - mobile: "meta", - cell: (row) => - row.entry?.answeredOn ? ( - {formatDay(row.entry.answeredOn)} - ) : ( - - ), - }, - { - id: "source", - label: "Source", - width: 168, - priority: 4, - mobile: "hidden", - cell: (row) => (row.entry?.source ? CENSUS_SOURCE[row.entry.source] : ), - }, - { - id: "reminders", - label: "Reminders", - width: 112, - align: "end", - priority: 5, - mobile: "hidden", - cell: (row) => row.entry?.reminders ?? 0, - }, - ] - return ( <> - {/* Phones keep the full width for Import responses: Send reminders becomes an icon there. */} - - ask("remind")} - /> - @@ -375,7 +311,7 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { icon={Archive} appearance="boxed" className="size-10 max-shell:flex-none" - onClick={() => ask("close")} + disabled /> ) : undefined @@ -383,6 +319,7 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { />
    + } @@ -428,7 +365,7 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { setQuery(value) setPage(1) }, - placeholder: "Search by name or email…", + placeholder: "Search by name…", }} filters={ <> @@ -488,46 +425,33 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { } /> row.id} - rowLabel={personName} - rowHref={(row) => `/dashboard/people/${row.id}`} + rowLabel={(row) => row.name} sort={sort} onSort={(next) => { setSort(next) setPage(1) }} - actions={ - canAct || canRead - ? (row) => ( - - {/* Answered rows open the answers; the others can be reminded while the campaign is open. */} - {!row.entry?.answer && canAct ? ( - remindOne(row)} - /> - ) : row.entry?.answer && canRead ? ( - { - setAnswers(row) - setAnswersOpen(true) - }} - /> - ) : ( - - )} - - ) - : undefined - } + actions={(row) => ( + + {/* Answered rows open the answers; the others can be reminded while the campaign is open. */} + {row.answer ? ( + + ) : isOpen ? ( + + ) : ( + + )} + + )} actionsWidth={60} pagination={{ page, @@ -571,53 +495,6 @@ export function CampaignPage({ detail }: { detail: CampaignDetail }) { />
    - - {canAct && confirm === "remind" ? ( - { - const result = await remind({ data: { campaignId: campaign.id, personId: null } }) - await refresh( - `${pluralize(result.sent, "reminder")} sent${result.skipped ? `, ${result.skipped} not sent` : ""}.`, - result.skipped ? "warning" : "success" - ) - }} - /> - ) : null} - {canAct && confirm === "close" ? ( - { - await close({ data: { campaignId: campaign.id } }) - await refresh("Campaign closed.") - }} - /> - ) : null} - {canAct ? ( - - ) : null} - {answers && canRead ? ( - - ) : null} ) } diff --git a/src/features/people/fee-dialogs.tsx b/src/features/people/fee-dialogs.tsx deleted file mode 100644 index b13dd47e..00000000 --- a/src/features/people/fee-dialogs.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import { useServerFn } from "@tanstack/react-start" -import { Bell, ExternalLink, FileText } from "lucide-react" -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react" - -import { - buttonMotion, - ConfirmDialog, - DateField, - fieldControl, - fieldHintId, - FormDialog, - FormField, - Hint, - InlineAlert, - KeyValueList, - SegmentedControl, - Spinner, -} from "@/components/primitives" -import { appToast } from "@/components/shell" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { PdfViewer, usePdfBytes } from "@/features/esign/pdf-viewer" -import { formatDateTime, pluralize } from "@/lib/format" -import { cn } from "@/lib/utils" - -import { centsToInput, formatCents, parseEuros } from "./people-fees" -import { - approveFee, - getFeeReview, - getFeeUpload, - markFeePaid, - rejectFee, - sendFeeReminders, -} from "./people-fees.functions" -import { fileBlob } from "./people-files" -import { FEE_STATUS, formatDay, PAYMENT_METHOD, REMINDER_SKIPPED, todayIso } from "./people-format" -import { errorMessage, useLoaded, useRefreshAfter } from "./people-hooks" -import { MonoChip, StateBadge } from "./people-ui" -import type { FilePayload, PaymentMethod } from "./people.contract" - -/** Reasons offered as one-click starts for Reject; the admin can edit the text. */ -const COMMON_REASONS = [ - { - label: "Wrong amount", - text: "The amount on the receipt isn't the fee. Transfer the rest and upload both receipts.", - }, - { - label: "Not a transfer receipt", - text: "The file is a screenshot of the payment page, not the transfer receipt. Upload the receipt your bank issued.", - }, - { label: "Unreadable file", text: "The file can't be read. Upload a clearer PDF or photo of the transfer receipt." }, -] - -export type FeeTarget = { id: string; name: string; number: string } - -/** Loads the member's current fee year once per opening. */ -function useFeeReview(personId: string) { - const load = useServerFn(getFeeReview) - const { data: review, failed } = useLoaded(useCallback(() => load({ data: { personId } }), [load, personId])) - return { review, failed } -} - -function Loading({ children }: { children: ReactNode }) { - return ( -

    - - {children} -

    - ) -} - -const PREVIEW_LABEL = "The uploaded transfer receipt" - -/** The uploaded transfer receipt: a PDF's pages or the image itself, with a link to open it in a new tab. */ -function UploadPreview({ file, size }: { file: FilePayload | null; size: number }) { - const pdfBytes = usePdfBytes(file?.contentType === "application/pdf" ? file.base64 : null) - const blob = useMemo(() => (file ? fileBlob(file) : null), [file]) - const [url, setUrl] = useState(null) - useEffect(() => { - if (!blob) return - const next = URL.createObjectURL(blob) - setUrl(next) - return () => URL.revokeObjectURL(next) - }, [blob]) - const image = file?.contentType.startsWith("image/") ?? false - return ( -
    -
    - - {file ? ( - - {file.fileName} - - ) : ( - Loading… - )} - {Math.max(1, Math.round(size / 1024))} KB - {url ? ( - - ) : null} -
    - {blob && url && image ? ( - {PREVIEW_LABEL} - ) : blob && !image ? ( - - ) : ( -
    - Loading the file… -
    - )} -
    - ) -} - -/** - * Review receipt: the member's uploaded transfer receipt next to their fee year. Approve marks the fee Paid and issues - * the next receipt to sign; Reject asks for a reason the member reads in My area and in the email. - */ -export function ReviewReceiptDialog({ - open, - onOpenChange, - person, -}: { - open: boolean - onOpenChange: (open: boolean) => void - person: FeeTarget -}) { - const { review, failed } = useFeeReview(person.id) - const loadFile = useServerFn(getFeeUpload) - const { data: file, failed: fileFailed } = useLoaded( - useCallback(() => loadFile({ data: { personId: person.id } }), [loadFile, person.id]) - ) - const approve = useServerFn(approveFee) - const reject = useServerFn(rejectFee) - const refresh = useRefreshAfter() - const [mode, setMode] = useState<"review" | "reject">("review") - const [reason, setReason] = useState("") - const [submitted, setSubmitted] = useState(false) - const firstName = person.name.split(" ")[0] ?? person.name - - const upload = review?.fee.upload ?? null - const reasonError = submitted && !reason.trim() ? "Say why, so the member knows what to fix." : undefined - - async function submit() { - if (!review || !upload) return - if (mode === "review") { - let issued: string - try { - issued = (await approve({ data: { personId: person.id } })).receipt - } catch (caught) { - console.error(caught) - throw new Error(errorMessage(caught, "Couldn't approve the receipt. Try again."), { cause: caught }) - } - await refresh(`Fee marked as paid. Receipt ${issued} is ready to sign.`) - onOpenChange(false) - return - } - setSubmitted(true) - if (!reason.trim()) return - try { - await reject({ data: { personId: person.id, reason: reason.trim() } }) - } catch (caught) { - console.error(caught) - throw new Error(errorMessage(caught, "Couldn't reject the receipt. Try again."), { cause: caught }) - } - await refresh(`Transfer receipt rejected. ${firstName} can upload a new one.`) - onOpenChange(false) - } - - return ( - - {person.name} · {review?.fee.year ?? ""} - - } - noun="review" - dirty={reason.trim() !== ""} - submitLabel={mode === "review" ? "Approve" : "Reject receipt"} - tone={mode === "review" ? undefined : "danger"} - canSubmit={review !== null && upload !== null} - onSubmit={submit} - footerStart={ - review && upload ? ( - mode === "review" ? ( - - ) : ( - - ) - ) : undefined - } - notice={ - review && upload && mode === "review" ? ( - - Approving marks the fee Paid and issues receipt {review.nextReceipt} for - signing. - - ) : undefined - } - > - {failed ? Couldn't load the fee. Close and try again. : null} - {!review && !failed ? Loading the receipt… : null} - {review && !upload ? ( - - There is no transfer receipt to review: someone may have just handled it. - - ) : null} - {review && upload ? ( -
    - {fileFailed ? ( - Couldn't load the file. The details below come from the upload. - ) : ( - - )} - {upload.amountCents !== review.fee.amountCents ? ( - - The declared amount is {formatCents(upload.amountCents)}, but the fee is{" "} - {formatCents(review.fee.amountCents)}. - - ) : null} - - {review.person.name} {review.memberNumber} - - ), - }, - { - key: "Microsoft 365", - value: {review.m365Email}, - }, - { - key: `Fee ${review.fee.year}`, - value: , - }, - { key: "Amount due", value: {formatCents(review.fee.amountCents)} }, - { - key: "Transfer", - value: ( - - {formatCents(upload.amountCents)} on {formatDay(upload.transferDate)}{" "} - as declared - - ), - }, - { key: "Uploaded", value: formatDateTime(new Date(upload.uploadedAt)) }, - { - key: "Earlier rejections", - value: review.rejections.length ? ( - - {review.rejections.map((rejection) => ( - - {formatDay(rejection.at.slice(0, 10))}: “{rejection.reason}” - - ))} - - ) : null, - }, - ]} - /> - {mode === "reject" ? ( -
    -
    - Common reasons -
    - {COMMON_REASONS.map((option) => ( - - ))} -
    -
    - -