From 1026d9b9e107f647d3535b2da5e9f561e11334b6 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 22:33:10 +0100 Subject: [PATCH] Badges : ordre de superposition des champs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre alphabétique de leur type). BadgeField.z_order (migration 0028, champs existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- backend/app/api/badges.py | 9 +- backend/app/api/schemas.py | 2 + .../versions/0028_badge_field_z_order.py | 45 ++++++++ backend/app/models/domain.py | 5 + backend/tests/test_badges_api.py | 38 ++++++ frontend/src/api/mock/mock-client.ts | 1 + frontend/src/api/schema.ts | 3 + frontend/src/i18n/locales/fr.json | 7 ++ frontend/src/pages/badges/BadgeCanvas.tsx | 109 +++++++++++------- .../src/pages/badges/BadgeCustomizeTab.tsx | 31 ++++- frontend/src/pages/badges/BadgeInspector.tsx | 60 +++++++++- frontend/src/pages/badges/model.ts | 56 ++++++++- 13 files changed, 327 insertions(+), 50 deletions(-) create mode 100644 backend/app/migrations/versions/0028_badge_field_z_order.py diff --git a/CLAUDE.md b/CLAUDE.md index a3167f6..a347e63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,15 @@ les tables sauf les tables d'association. zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`), `auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne - sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`). + sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`), + `z_order` (ordre d'empilement sur la face : les champs sont dessinés du + plus petit au plus grand, à égalité la photo dessous — avant, l'ordre + alphabétique des types faisait passer la photo par-dessus le nom et le + texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1, + `badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de + l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu + suit le même ordre, le cadre du champ sélectionné restant par-dessus pour + qu'un champ recouvert se saisisse encore). `FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la ligne. Édité visuellement depuis l'écran « Badges » @@ -982,6 +990,7 @@ Aucune dépendance locale requise hors Docker. | 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider | | 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider | | 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider | +| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index 177104b..328c99f 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -86,7 +86,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]: session.exec( select(BadgeField) .where(BadgeField.edition_id == edition_id) - .order_by(BadgeField.face, BadgeField.field_type) + # Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la + # photo dessous, pour ne jamais masquer un texte. + .order_by( + BadgeField.face, + BadgeField.z_order, + (BadgeField.field_type == BadgeFieldType.PHOTO).desc(), + BadgeField.field_type, + ) ) ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index a26f564..9da2cdb 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -369,6 +369,8 @@ class BadgeFieldIn(ApiModel): auto_fit: bool = False # `PHOTO` seulement, ignoré sinon. photo_shape: BadgePhotoShape = BadgePhotoShape.RECT + # Ordre d'empilement sur la face (voir `BadgeField.z_order`). + z_order: int = 0 class BadgeFieldsSet(ApiModel): diff --git a/backend/app/migrations/versions/0028_badge_field_z_order.py b/backend/app/migrations/versions/0028_badge_field_z_order.py new file mode 100644 index 0000000..1b81c86 --- /dev/null +++ b/backend/app/migrations/versions/0028_badge_field_z_order.py @@ -0,0 +1,45 @@ +"""Ordre d'empilement des champs du badge + +Revision ID: 0028_badge_field_z_order +Revises: 0027_volunteer_badge_count +Create Date: 2026-10-27 + +`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au +plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et +la photo recouvrait le nom et le texte libre. Les champs existants sont +numérotés photo d'abord (dessous), puis les autres par type. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0028_badge_field_z_order" +down_revision: str | None = "0027_volunteer_badge_count" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "badge_field", + sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"), + ) + op.execute( + """ + UPDATE badge_field AS b SET z_order = ranked.rank + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY edition_id, face + ORDER BY (field_type = 'PHOTO') DESC, field_type + ) - 1 AS rank + FROM badge_field + ) AS ranked + WHERE b.id = ranked.id + """ + ) + + +def downgrade() -> None: + op.drop_column("badge_field", "z_order") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 47daa81..2077333 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -509,6 +509,11 @@ class BadgeField(TimestampMixin, table=True): server_default=BadgePhotoShape.RECT.value, ), ) + # Ordre d'empilement sur sa face : les champs sont dessinés du plus petit + # au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur. + z_order: int = Field( + default=0, sa_column=Column(Integer, nullable=False, server_default="0") + ) # --------------------------------------------------------------------------- # diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index b3f2592..2cdc5df 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -398,3 +398,41 @@ def test_badge_count_out_of_range_is_rejected(client, edition, session, count): f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count} ) assert resp.status_code == 422 + + +def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch): + import app.api.badges as badges_api + + captured = {} + original = badges_api.generate_badge_sheets_pdf + + def spy(badges, *args, **kwargs): + captured["badges"] = badges + return original(badges, *args, **kwargs) + + monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy) + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + _field(field_type="FREE_TEXT", text="Staff", z_order=2), + _field(field_type="FULL_NAME", z_order=0), + _field(field_type="PHOTO", z_order=1), + ] + }, + ) + assert resp.status_code == 200 + assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"] + make_volunteer(session, edition, first_name="Alex", last_name="Martin") + + assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200 + (badge,) = captured["badges"] + assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"] + + +def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]}, + ) + assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"] diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 42946ca..f93ef32 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -1606,6 +1606,7 @@ export function createMockApi(): PlanningApi { bg_color: f.bg_color ?? null, auto_fit: f.auto_fit ?? true, photo_shape: f.photo_shape ?? 'RECT', + z_order: f.z_order ?? 0, id: `bf${state.seq++}`, edition_id: state.edition.id, })); diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 1d22cb4..9060e36 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -462,6 +462,9 @@ export interface BadgeFieldIn { auto_fit: boolean; /** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */ photo_shape: BadgePhotoShape; + /** Ordre d'empilement sur la face : un champ recouvre ceux d'un + * `z_order` inférieur (photo dessous à égalité). */ + z_order: number; /** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour * un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */ text?: string | null; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 4a3480e..8d307dd 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -480,6 +480,13 @@ "badges.inspector.h": "H", "badges.inspector.center": "↔ Centrer", "badges.inspector.fullWidth": "⇤⇥ Pleine largeur", + "badges.inspector.stack": "Superposition", + "badges.inspector.stack.position": "{rank} / {total} · 1 = dessus", + "badges.inspector.stack.front": "⤒ Premier plan", + "badges.inspector.stack.forward": "↑ Avancer", + "badges.inspector.stack.backward": "↓ Reculer", + "badges.inspector.stack.back": "⤓ Arrière-plan", + "badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.", "badges.inspector.hide": "Masquer ce champ", "badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.", "badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.", diff --git a/frontend/src/pages/badges/BadgeCanvas.tsx b/frontend/src/pages/badges/BadgeCanvas.tsx index 1aacacd..3da0e5f 100644 --- a/frontend/src/pages/badges/BadgeCanvas.tsx +++ b/frontend/src/pages/badges/BadgeCanvas.tsx @@ -20,6 +20,7 @@ import { isVerticallyCentered, renderField, SAFE_MARGIN_MM, + stackOrder, type BadgeData, type FieldState, } from './model'; @@ -84,7 +85,8 @@ export function BadgeCanvas({ onDropImage, }: BadgeCanvasProps) { const [dropping, setDropping] = useState(false); - const shown = fields.filter((f) => f.enabled); + // Du dessous vers le dessus, comme le PDF (`z_order`). + const shown = stackOrder(fields, face); const isFaceSelected = shown.some((f) => f.field_type === selectedType); return ( @@ -148,10 +150,11 @@ export function BadgeCanvas({ style={{ inset: SAFE_MARGIN_MM * scale }} /> ) : null} - {shown.map((field) => ( + {shown.map((field, i) => ( - {rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( - - ) : rendered?.kind === 'table' ? ( - - ) : rendered?.kind === 'text' ? ( - - ) : null} + const geometry = { + left: field.x_mm * scale, + top: field.y_mm * scale, + width: field.width_mm * scale, + height: field.height_mm * scale, + }; + const handlers = { + onPointerDown: start('move'), + onPointerMove: move, + onPointerUp: end, + onPointerCancel: end, + }; - {truncated ? ( - - {t('badges.preview.truncated')} - - ) : null} + return ( + <> + {/* Contenu à son rang dans la pile, comme sur le badge imprimé. */} +
+ {rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( + + ) : rendered?.kind === 'table' ? ( + + ) : rendered?.kind === 'text' ? ( + + ) : null} + + {truncated && !selected ? : null} +
+ + {/* Cadre de sélection par-dessus toute la pile : un champ recouvert + (ex. la photo sous un texte) reste saisissable, sans que l'aperçu + change l'ordre d'impression. */} {selected ? ( - <> +
+ {truncated ? : null} {t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })} {layout?.reduced @@ -315,9 +334,17 @@ function FieldBox({ onPointerCancel={end} className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]" /> - +
) : null} - + + ); +} + +function TruncatedTag() { + return ( + + {t('badges.preview.truncated')} + ); } diff --git a/frontend/src/pages/badges/BadgeCustomizeTab.tsx b/frontend/src/pages/badges/BadgeCustomizeTab.tsx index cdbb7ca..ae49435 100644 --- a/frontend/src/pages/badges/BadgeCustomizeTab.tsx +++ b/frontend/src/pages/badges/BadgeCustomizeTab.tsx @@ -52,11 +52,15 @@ import { fitToBadge, fmt, renderField, + restack, sameRef, + stackOrder, toPayload, + zOrderOnEnable, type BadgeData, type FieldRef, type FieldState, + type StackMove, } from './model'; const HISTORY_LIMIT = 40; @@ -95,22 +99,33 @@ type EditorAction = badgeW: number; badgeH: number; } + | { type: 'restack'; ref: FieldRef; move: StackMove } | { type: 'fit'; badgeW: number; badgeH: number } | { type: 'undo' }; function editorReducer(state: EditorState, action: EditorAction): EditorState { switch (action.type) { - case 'patch': + case 'patch': { + // Un champ qu'on active prend place en haut de la pile (la photo en bas). + const target = state.fields.find((f) => sameRef({ face: f.face, type: f.field_type }, action.ref)); + const patch = + action.patch.enabled && target && !target.enabled + ? { z_order: zOrderOnEnable(state.fields, action.ref), ...action.patch } + : action.patch; return { history: action.record ? [...state.history, state.fields].slice(-HISTORY_LIMIT) : state.history, fields: state.fields.map((f) => - sameRef({ face: f.face, type: f.field_type }, action.ref) - ? fitToBadge({ ...f, ...action.patch }, action.badgeW, action.badgeH) - : f, + f === target ? fitToBadge({ ...f, ...patch }, action.badgeW, action.badgeH) : f, ), }; + } + case 'restack': + return { + history: [...state.history, state.fields].slice(-HISTORY_LIMIT), + fields: restack(state.fields, action.ref, action.move), + }; case 'fit': return { ...state, @@ -540,9 +555,17 @@ function BadgeEditor({ } photoCount={data.volunteers.filter((v) => v.has_photo).length} volunteerCount={total} + stack={stackOrder(fields, selectedField.face).map((f) => f.field_type)} onChange={(change) => patch({ face: selectedField.face, type: selectedField.field_type }, change) } + onRestack={(move: StackMove) => + dispatch({ + type: 'restack', + ref: { face: selectedField.face, type: selectedField.field_type }, + move, + }) + } onHide={() => { patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false }); setSelected(null); diff --git a/frontend/src/pages/badges/BadgeInspector.tsx b/frontend/src/pages/badges/BadgeInspector.tsx index f2811a3..2e9bf8f 100644 --- a/frontend/src/pages/badges/BadgeInspector.tsx +++ b/frontend/src/pages/badges/BadgeInspector.tsx @@ -1,11 +1,11 @@ import { useEffect, useState, type ReactNode } from 'react'; -import type { BadgeAlign } from '@/api/schema'; +import type { BadgeAlign, BadgeFieldType } from '@/api/schema'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout'; -import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model'; +import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState, type StackMove } from './model'; const TEXT_COLORS: Array<{ hex: string; key: string }> = [ { hex: '#FFFFFF', key: 'badges.color.white' }, @@ -142,7 +142,9 @@ export function BadgeInspector({ effectiveFontSize, photoCount, volunteerCount, + stack, onChange, + onRestack, onHide, }: { field: FieldState; @@ -152,7 +154,10 @@ export function BadgeInspector({ effectiveFontSize: number | null; photoCount: number; volunteerCount: number; + /** Types des champs activés de la face, du dessous vers le dessus. */ + stack: BadgeFieldType[]; onChange: (patch: Partial) => void; + onRestack: (move: StackMove) => void; onHide: () => void; }) { const isPhoto = field.field_type === 'PHOTO'; @@ -400,6 +405,8 @@ export function BadgeInspector({ + + + ); + })} + +
+ {t('badges.inspector.stack.help')} +
+ + ); +} diff --git a/frontend/src/pages/badges/model.ts b/frontend/src/pages/badges/model.ts index a235b50..be9b9eb 100644 --- a/frontend/src/pages/badges/model.ts +++ b/frontend/src/pages/badges/model.ts @@ -67,7 +67,7 @@ export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean { type FieldDefaults = Omit; -const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const; +const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT', z_order: 0 } as const; // Position/taille par défaut à l'activation d'un champ — empilés // verticalement pour ne pas se superposer (repères pour un badge CR80 @@ -148,6 +148,7 @@ export function fieldsFromServer( bg_color: existing.bg_color ?? null, auto_fit: existing.auto_fit ?? true, photo_shape: existing.photo_shape ?? 'RECT', + z_order: existing.z_order ?? 0, text: existing.text ?? null, enabled: true, } @@ -161,6 +162,7 @@ export function fieldsFromServer( /** Corps du `PUT .../badges/template/fields` : les champs activés des deux * faces. */ export function toPayload(fields: FieldState[]): BadgeFieldIn[] { + const rank = stackRanks(fields); return fields .filter((f) => f.enabled) .map((f) => ({ @@ -177,10 +179,62 @@ export function toPayload(fields: FieldState[]): BadgeFieldIn[] { bg_color: f.bg_color, auto_fit: f.auto_fit, photo_shape: f.photo_shape, + z_order: rank.get(f) ?? 0, text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null, })); } +// --------------------------------------------------------------------------- // +// Ordre d'empilement // +// --------------------------------------------------------------------------- // +/** Champs activés d'une face, du dessous vers le dessus — l'ordre de dessin + * du PDF. À `z_order` égal, la photo passe dessous, puis l'ordre de + * `FIELD_TYPES` (le serveur ne départage que la photo, mais les `z_order` + * envoyés sont toujours distincts : voir `stackRanks`). */ +export function stackOrder(fields: FieldState[], face: BadgeFace): FieldState[] { + return fields + .filter((f) => f.enabled && f.face === face) + .sort( + (a, b) => + a.z_order - b.z_order || + FIELD_TYPES.indexOf(a.field_type) - FIELD_TYPES.indexOf(b.field_type), + ); +} + +/** Rang de chaque champ activé dans la pile de sa face (0 = dessous). */ +export function stackRanks(fields: FieldState[]): Map { + const ranks = new Map(); + for (const face of FACES) stackOrder(fields, face).forEach((f, i) => ranks.set(f, i)); + return ranks; +} + +export type StackMove = 'front' | 'forward' | 'backward' | 'back'; + +/** Déplace un champ dans la pile de sa face et renumérote la face (0…n-1). */ +export function restack(fields: FieldState[], ref: FieldRef, move: StackMove): FieldState[] { + const stack = stackOrder(fields, ref.face); + const from = stack.findIndex((f) => f.field_type === ref.type); + if (from < 0) return fields; + const to = { + front: stack.length - 1, + forward: Math.min(from + 1, stack.length - 1), + backward: Math.max(from - 1, 0), + back: 0, + }[move]; + const [moved] = stack.splice(from, 1); + stack.splice(to, 0, moved); + const rank = new Map(stack.map((f, i) => [f, i])); + return fields.map((f) => (rank.has(f) ? { ...f, z_order: rank.get(f) as number } : f)); +} + +/** `z_order` d'un champ qu'on active : au-dessus des autres — sauf la + * photo, dessous, pour ne pas masquer un texte déjà placé. */ +export function zOrderOnEnable(fields: FieldState[], ref: FieldRef): number { + const stack = stackOrder(fields, ref.face); + if (!stack.length) return 0; + return ref.type === 'PHOTO' ? stack[0].z_order - 1 : stack[stack.length - 1].z_order + 1; +} + export function round1(value: number): number { return Math.round(value * 10) / 10; }