diff --git a/app/features/display-board/ui/dynamic/OrganigramDocument.tsx b/app/features/display-board/ui/dynamic/OrganigramDocument.tsx index 9e88fd77..c2c4c8bb 100644 --- a/app/features/display-board/ui/dynamic/OrganigramDocument.tsx +++ b/app/features/display-board/ui/dynamic/OrganigramDocument.tsx @@ -2,7 +2,14 @@ import path from 'node:path' import { Document, Font, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import type { OrganigramHolder, OrganigramNode } from '~/shared/domain/organigram.queries' import type { BandBlock, CommitteeBlock, RosterBlock } from '~/shared/domain/organigram-layout' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from '~/shared/domain/organigram-layout' +import { + bandAdjoints, + groupLayout, + responsibilityEyebrow, + seatLabel, + teamRows, + toLayout, +} from '~/shared/domain/organigram-layout' import { sanitizeText } from '~/shared/utils/sanitize-text' // The printable « Organisation des services » — the same sheet the board shows, as the A4 page @@ -108,13 +115,13 @@ function Line({ node }: { node: OrganigramNode }) { ) } -function ServiceWithTeams({ node, teams }: { node: OrganigramNode; teams: OrganigramNode[] }) { +function ServiceWithTeams({ node, rows }: { node: OrganigramNode; rows: OrganigramNode[] }) { + // Only group roles are teams; the personal roles among `rows` are adjoint arrangements and + // reach the reader through the ADJOINTS segment instead. Both halves of that rule live in + // organigram-layout so this view and the PDF cannot drift apart on it. + const teams = teamRows(rows) const leaders = node.holders.filter(holder => holder.kind === 'leader') - const seen = new Set(leaders.map(leader => leader.memberId)) - const deputies = [ - ...node.holders.filter(holder => holder.kind === 'deputy'), - ...teams.flatMap(team => team.holders.filter(holder => holder.kind === 'leader')), - ].filter(deputy => !seen.has(deputy.memberId) && seen.add(deputy.memberId)) + const deputies = bandAdjoints(node, rows) return ( @@ -196,7 +203,7 @@ function Bands({ bands }: { bands: BandBlock[] }) { <> {bands.map(band => band.node ? ( - + ) : ( band.rows.map(row => ) ), @@ -246,7 +253,7 @@ export function OrganigramDocument({ {legacy.map(block => { if (block.kind === 'row') return - if (block.node) return + if (block.node) return return block.rows.map(row => ) })} diff --git a/app/features/display-board/ui/dynamic/OrganigramView.tsx b/app/features/display-board/ui/dynamic/OrganigramView.tsx index 026d1606..ef4e828c 100644 --- a/app/features/display-board/ui/dynamic/OrganigramView.tsx +++ b/app/features/display-board/ui/dynamic/OrganigramView.tsx @@ -1,7 +1,14 @@ import * as m from '~/i18n/paraglide/messages' import type { OrganigramNode } from '~/shared/domain/organigram.queries' import type { BandBlock, CommitteeBlock, RosterBlock } from '~/shared/domain/organigram-layout' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from '~/shared/domain/organigram-layout' +import { + bandAdjoints, + groupLayout, + responsibilityEyebrow, + seatLabel, + teamRows, + toLayout, +} from '~/shared/domain/organigram-layout' import { cn } from '~/shared/utils/utils' // The board's rendering of the organigram — a document, not a tool. @@ -79,17 +86,19 @@ const dot = ( * A service and its teams as one line: « Audio/Vidéo — RESPONSABLE Philippe MARTIN · ADJOINTS * Sébastien ROUX, Jérôme MULLER · ÉQUIPES Perches, Estrade, Sono ». * - * The team préposés fold into the service's adjoints — that is what they are to the person - * reading the sheet: who helps the responsable run this. The teams themselves become names, - * because who to ask for matters on a noticeboard; the full roster never did. + * The leaders of everything beneath fold into the service's adjoints — that is what they are to + * the person reading the sheet: who helps the responsable run this. Group roles then become + * names on the ÉQUIPES line, because who to ask for matters on a noticeboard; the full roster + * never did. A nested personal role is not a team and is not named there — it is already on the + * line above, as an adjoint. */ -function ServiceWithTeams({ node, teams }: { node: OrganigramNode; teams: OrganigramNode[] }) { +function ServiceWithTeams({ node, rows }: { node: OrganigramNode; rows: OrganigramNode[] }) { + // Only group roles are teams; the personal roles among `rows` are adjoint arrangements and + // reach the reader through the ADJOINTS segment instead. Both halves of that rule live in + // organigram-layout so this view and the PDF cannot drift apart on it. + const teams = teamRows(rows) const leaders = node.holders.filter(holder => holder.kind === 'leader') - const seen = new Set(leaders.map(leader => leader.memberId)) - const deputies = [ - ...node.holders.filter(holder => holder.kind === 'deputy'), - ...teams.flatMap(team => team.holders.filter(holder => holder.kind === 'leader')), - ].filter(deputy => !seen.has(deputy.memberId) && seen.add(deputy.memberId)) + const deputies = bandAdjoints(node, rows) return (
@@ -202,7 +211,7 @@ function BranchSection({ under, bands }: { under: string; bands: BandBlock[] })
{bands.map(band => band.node ? ( - + ) : ( band.rows.map(row => ) ), @@ -247,7 +256,7 @@ export function OrganigramView({ tree }: { tree: OrganigramNode[] }) {
{legacy.map(block => { if (block.kind === 'row') return - if (block.node) return + if (block.node) return return block.rows.map(row => ) })}
diff --git a/app/shared/domain/organigram-layout.test.ts b/app/shared/domain/organigram-layout.test.ts index 4243c954..82b1c03f 100644 --- a/app/shared/domain/organigram-layout.test.ts +++ b/app/shared/domain/organigram-layout.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { SERVICE_COMMITTEE_KEY, SERVICE_COMMITTEE_POST_KEYS } from '~/shared/domain/built-in-roles.server' import type { OrganigramNode } from '~/shared/domain/organigram.queries' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from './organigram-layout' +import { bandAdjoints, groupLayout, responsibilityEyebrow, seatLabel, teamRows, toLayout } from './organigram-layout' // The printed "Organisation des services" sheet has no connector lines and no deep indentation. // It groups children under band headers — « Sous la responsabilité du secrétaire » — and prints @@ -247,3 +247,95 @@ describe('responsibilityEyebrow — the French contraction', () => { expect(responsibilityEyebrow('Équipe technique')).toBe('Sous la responsabilité de l’') }) }) + +// A band's rows are not all teams. A personal role nested under another personal role is an +// adjoint arrangement — the settled model treats a child personal role as an adjoint, and its +// titulaire already folds into the parent's ADJOINTS — so naming it on the « ÉQUIPES » line +// announces a post as if it were a team, which is what the board sheet was doing. +describe('teamRows', () => { + const porte = node({ name: 'Porte' }) + const auditorium = node({ name: 'Auditorium' }) + const adjointPost = node({ name: 'Responsable du programme des services', isSinglePerson: true }) + + it('keeps group roles', () => { + expect(teamRows([porte, auditorium]).map(row => row.name)).toEqual(['Porte', 'Auditorium']) + }) + + it('drops a personal role, which is an adjoint and not a team', () => { + expect(teamRows([adjointPost]).map(row => row.name)).toEqual([]) + }) + + it('keeps the teams and drops the post when a band has both', () => { + expect(teamRows([porte, adjointPost, auditorium]).map(row => row.name)).toEqual(['Porte', 'Auditorium']) + }) + + it('preserves order', () => { + expect(teamRows([auditorium, porte]).map(row => row.name)).toEqual(['Auditorium', 'Porte']) + }) + + it('returns nothing for an empty band', () => { + expect(teamRows([])).toEqual([]) + }) +}) + +// The other half of the same rule. A band folds the leaders of everything beneath it into its +// ADJOINTS line — that is how a team's préposé and a nested post's titulaire both reach the +// reader. Extracted from the two renderers, which carried identical copies: the screen view and +// the PDF drifting apart is how a fix lands in one and not the other. +describe('bandAdjoints', () => { + const holder = (memberId: number, kind: string) => ({ + roleId: 0, + memberId, + firstname: `F${memberId}`, + lastname: `L${memberId}`, + anonymizedAt: null, + kind, + isElder: false, + }) + + it("folds a team's leader in as an adjoint", () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2]) + }) + + // The point of the fix: a nested personal role contributes its titulaire here, so removing its + // name from the ÉQUIPES line loses nothing — the person is still on the sheet. + it("folds a nested post's titulaire in as an adjoint", () => { + const post = node({ isSinglePerson: true, holders: [holder(1, 'leader')] }) + const child = node({ isSinglePerson: true, holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(post, [child]).map(a => a.memberId)).toEqual([2]) + expect(teamRows([child])).toEqual([]) + }) + + it("keeps the band's own deputies, ahead of the folded ones", () => { + const service = node({ holders: [holder(1, 'leader'), holder(2, 'deputy')] }) + const team = node({ holders: [holder(3, 'leader')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2, 3]) + }) + + it('never repeats someone who already leads the band', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(1, 'leader')] }) + + expect(bandAdjoints(service, [team])).toEqual([]) + }) + + it('lists someone leading two teams once', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const a = node({ holders: [holder(2, 'leader')] }) + const b = node({ holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(service, [a, b]).map(x => x.memberId)).toEqual([2]) + }) + + it('ignores plain members beneath the band', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(2, 'leader'), holder(3, 'member')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2]) + }) +}) diff --git a/app/shared/domain/organigram-layout.ts b/app/shared/domain/organigram-layout.ts index e394e80c..285e63bf 100644 --- a/app/shared/domain/organigram-layout.ts +++ b/app/shared/domain/organigram-layout.ts @@ -1,4 +1,4 @@ -import type { OrganigramNode } from '~/shared/domain/organigram.queries' +import type { OrganigramHolder, OrganigramNode } from '~/shared/domain/organigram.queries' // Turning the tree into the layout the printed sheet uses. // @@ -49,7 +49,13 @@ export interface BandBlock { node: OrganigramNode | null /** Whose responsibility this band falls under — «Sous la responsabilité : Coordinateur». */ under: string | null - /** Rendered beneath, in order. */ + /** + * Rendered beneath, in order — and deliberately heterogeneous: group roles (the real teams) + * sit alongside personal roles (adjoint arrangements). A caller that treats the two alike is + * the bug this list has already caused once, naming a post on the « ÉQUIPES » line. Split + * with `teamRows` before naming anything a team; a collector band (`node: null`) prints the + * list unfiltered, as full lines, where a personal role does belong. + */ rows: OrganigramNode[] } @@ -160,6 +166,38 @@ export function seatLabel( return holder.isElder ? 'Responsable' : 'Préposé' } +/** + * The rows a band names on its « ÉQUIPES » line. + * + * A band's rows are not all teams. A personal role nested under another personal role is an + * adjoint arrangement — the settled model treats a child personal role as an adjoint, and + * ServiceWithTeams already folds its titulaire into the parent's ADJOINTS. Naming it here as + * well announced a post as if it were a team: « ÉQUIPE Responsable du programme des services ». + * + * Filtered at render time rather than in `toLayout`, because a collector band (`node: null`) + * prints the same rows as full lines, people and all — there a personal role belongs. + */ +export function teamRows(rows: OrganigramNode[]): OrganigramNode[] { + return rows.filter(row => !row.isSinglePerson) +} + +/** + * The people a band prints as its ADJOINTS. + * + * Its own deputies first, then the leaders of everything beneath it — a team's préposé and a + * nested post's titulaire alike. That fold is why `teamRows` can drop a nested post from the + * ÉQUIPES line without losing anybody: the person is already here. + * + * Anyone already leading the band is skipped, and nobody appears twice. + */ +export function bandAdjoints(node: OrganigramNode, rows: OrganigramNode[]): OrganigramHolder[] { + const seen = new Set(node.holders.filter(holder => holder.kind === 'leader').map(leader => leader.memberId)) + return [ + ...node.holders.filter(holder => holder.kind === 'deputy'), + ...rows.flatMap(row => row.holders.filter(holder => holder.kind === 'leader')), + ].filter(adjoint => !seen.has(adjoint.memberId) && seen.add(adjoint.memberId)) +} + export interface GroupedLayout { rosters: RosterBlock[] committee: CommitteeBlock | null