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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions app/features/display-board/ui/dynamic/OrganigramDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<View style={styles.line} wrap={false}>
Expand Down Expand Up @@ -196,7 +203,7 @@ function Bands({ bands }: { bands: BandBlock[] }) {
<>
{bands.map(band =>
band.node ? (
<ServiceWithTeams key={band.id} node={band.node} teams={band.rows} />
<ServiceWithTeams key={band.id} node={band.node} rows={band.rows} />
) : (
band.rows.map(row => <Line key={row.id} node={row} />)
),
Expand Down Expand Up @@ -246,7 +253,7 @@ export function OrganigramDocument({
<View style={styles.section}>
{legacy.map(block => {
if (block.kind === 'row') return <Line key={block.id} node={block.node} />
if (block.node) return <ServiceWithTeams key={block.id} node={block.node} teams={block.rows} />
if (block.node) return <ServiceWithTeams key={block.id} node={block.node} rows={block.rows} />
return block.rows.map(row => <Line key={row.id} node={row} />)
})}
</View>
Expand Down
33 changes: 21 additions & 12 deletions app/features/display-board/ui/dynamic/OrganigramView.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-0.5 py-1.5 sm:flex-row sm:items-baseline sm:gap-6">
Expand Down Expand Up @@ -202,7 +211,7 @@ function BranchSection({ under, bands }: { under: string; bands: BandBlock[] })
<div className="flex flex-col gap-1">
{bands.map(band =>
band.node ? (
<ServiceWithTeams key={band.id} node={band.node} teams={band.rows} />
<ServiceWithTeams key={band.id} node={band.node} rows={band.rows} />
) : (
band.rows.map(row => <Line key={row.id} node={row} />)
),
Expand Down Expand Up @@ -247,7 +256,7 @@ export function OrganigramView({ tree }: { tree: OrganigramNode[] }) {
<section className="flex flex-col gap-3 border-t pt-4">
{legacy.map(block => {
if (block.kind === 'row') return <Line key={block.id} node={block.node} />
if (block.node) return <ServiceWithTeams key={block.id} node={block.node} teams={block.rows} />
if (block.node) return <ServiceWithTeams key={block.id} node={block.node} rows={block.rows} />
return block.rows.map(row => <Line key={row.id} node={row} />)
})}
</section>
Expand Down
94 changes: 93 additions & 1 deletion app/shared/domain/organigram-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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])
})
})
42 changes: 40 additions & 2 deletions app/shared/domain/organigram-layout.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down Expand Up @@ -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[]
}

Expand Down Expand Up @@ -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
Expand Down