Badges : ordre de superposition des champs
ci-cd / build-backend (push) Failing after 1m5s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 22:33:10 +01:00
co-authored by Claude Opus 5.5
parent 4f86398ccb
commit 1026d9b9e1
13 changed files with 327 additions and 50 deletions
+10 -1
View File
@@ -176,7 +176,15 @@ les tables sauf les tables d'association.
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`), 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 `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 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 `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 dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
ligne. Édité visuellement depuis l'écran « Badges » 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+8 -1
View File
@@ -86,7 +86,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec( session.exec(
select(BadgeField) select(BadgeField)
.where(BadgeField.edition_id == edition_id) .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,
)
) )
) )
+2
View File
@@ -369,6 +369,8 @@ class BadgeFieldIn(ApiModel):
auto_fit: bool = False auto_fit: bool = False
# `PHOTO` seulement, ignoré sinon. # `PHOTO` seulement, ignoré sinon.
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
z_order: int = 0
class BadgeFieldsSet(ApiModel): class BadgeFieldsSet(ApiModel):
@@ -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")
+5
View File
@@ -509,6 +509,11 @@ class BadgeField(TimestampMixin, table=True):
server_default=BadgePhotoShape.RECT.value, 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")
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+38
View File
@@ -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} f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
) )
assert resp.status_code == 422 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"]
+1
View File
@@ -1606,6 +1606,7 @@ export function createMockApi(): PlanningApi {
bg_color: f.bg_color ?? null, bg_color: f.bg_color ?? null,
auto_fit: f.auto_fit ?? true, auto_fit: f.auto_fit ?? true,
photo_shape: f.photo_shape ?? 'RECT', photo_shape: f.photo_shape ?? 'RECT',
z_order: f.z_order ?? 0,
id: `bf${state.seq++}`, id: `bf${state.seq++}`,
edition_id: state.edition.id, edition_id: state.edition.id,
})); }));
+3
View File
@@ -462,6 +462,9 @@ export interface BadgeFieldIn {
auto_fit: boolean; auto_fit: boolean;
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */ /** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
photo_shape: BadgePhotoShape; 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 /** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */ * un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null; text?: string | null;
+7
View File
@@ -480,6 +480,13 @@
"badges.inspector.h": "H", "badges.inspector.h": "H",
"badges.inspector.center": "↔ Centrer", "badges.inspector.center": "↔ Centrer",
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur", "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.hide": "Masquer ce champ",
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.", "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.", "badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
+68 -41
View File
@@ -20,6 +20,7 @@ import {
isVerticallyCentered, isVerticallyCentered,
renderField, renderField,
SAFE_MARGIN_MM, SAFE_MARGIN_MM,
stackOrder,
type BadgeData, type BadgeData,
type FieldState, type FieldState,
} from './model'; } from './model';
@@ -84,7 +85,8 @@ export function BadgeCanvas({
onDropImage, onDropImage,
}: BadgeCanvasProps) { }: BadgeCanvasProps) {
const [dropping, setDropping] = useState(false); 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); const isFaceSelected = shown.some((f) => f.field_type === selectedType);
return ( return (
@@ -148,10 +150,11 @@ export function BadgeCanvas({
style={{ inset: SAFE_MARGIN_MM * scale }} style={{ inset: SAFE_MARGIN_MM * scale }}
/> />
) : null} ) : null}
{shown.map((field) => ( {shown.map((field, i) => (
<FieldBox <FieldBox
key={field.field_type} key={field.field_type}
field={field} field={field}
layer={i + 1}
data={data} data={data}
volunteer={volunteer} volunteer={volunteer}
photoUrl={photoUrl} photoUrl={photoUrl}
@@ -171,6 +174,7 @@ export function BadgeCanvas({
function FieldBox({ function FieldBox({
field, field,
layer,
data, data,
volunteer, volunteer,
photoUrl, photoUrl,
@@ -183,6 +187,8 @@ function FieldBox({
onChange, onChange,
}: { }: {
field: FieldState; field: FieldState;
/** Rang dans la pile de la face (`z-index`). */
layer: number;
data: BadgeData; data: BadgeData;
volunteer: VolunteerRead | null; volunteer: VolunteerRead | null;
photoUrl: string | null; photoUrl: string | null;
@@ -263,45 +269,58 @@ function FieldBox({
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null; const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
const truncated = layout?.truncated ?? false; const truncated = layout?.truncated ?? false;
return ( const geometry = {
<div left: field.x_mm * scale,
onPointerDown={start('move')} top: field.y_mm * scale,
onPointerMove={move} width: field.width_mm * scale,
onPointerUp={end} height: field.height_mm * scale,
onPointerCancel={end} };
className="absolute cursor-move touch-none" const handlers = {
style={{ onPointerDown: start('move'),
left: field.x_mm * scale, onPointerMove: move,
top: field.y_mm * scale, onPointerUp: end,
width: field.width_mm * scale, onPointerCancel: end,
height: field.height_mm * scale, };
outline: selected
? '2px solid #2A6BFF'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
// Le champ sélectionné passe devant les autres : sinon un champ
// recouvert (ex. la photo sous un texte) ne se saisit plus.
zIndex: selected ? 10 : undefined,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated ? ( return (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none"> <>
{t('badges.preview.truncated')} {/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
</span> <div
) : null} {...(selected ? {} : handlers)}
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
style={{
...geometry,
outline: selected
? 'none'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
zIndex: layer,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated && !selected ? <TruncatedTag /> : null}
</div>
{/* 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 ? ( {selected ? (
<> <div
{...handlers}
className="absolute z-[15] cursor-move touch-none"
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
>
{truncated ? <TruncatedTag /> : null}
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white"> <span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })} {t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
{layout?.reduced {layout?.reduced
@@ -315,9 +334,17 @@ function FieldBox({
onPointerCancel={end} 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]" 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]"
/> />
</> </div>
) : null} ) : null}
</div> </>
);
}
function TruncatedTag() {
return (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
); );
} }
@@ -52,11 +52,15 @@ import {
fitToBadge, fitToBadge,
fmt, fmt,
renderField, renderField,
restack,
sameRef, sameRef,
stackOrder,
toPayload, toPayload,
zOrderOnEnable,
type BadgeData, type BadgeData,
type FieldRef, type FieldRef,
type FieldState, type FieldState,
type StackMove,
} from './model'; } from './model';
const HISTORY_LIMIT = 40; const HISTORY_LIMIT = 40;
@@ -95,22 +99,33 @@ type EditorAction =
badgeW: number; badgeW: number;
badgeH: number; badgeH: number;
} }
| { type: 'restack'; ref: FieldRef; move: StackMove }
| { type: 'fit'; badgeW: number; badgeH: number } | { type: 'fit'; badgeW: number; badgeH: number }
| { type: 'undo' }; | { type: 'undo' };
function editorReducer(state: EditorState, action: EditorAction): EditorState { function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) { 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 { return {
history: action.record history: action.record
? [...state.history, state.fields].slice(-HISTORY_LIMIT) ? [...state.history, state.fields].slice(-HISTORY_LIMIT)
: state.history, : state.history,
fields: state.fields.map((f) => fields: state.fields.map((f) =>
sameRef({ face: f.face, type: f.field_type }, action.ref) f === target ? fitToBadge({ ...f, ...patch }, action.badgeW, action.badgeH) : f,
? fitToBadge({ ...f, ...action.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': case 'fit':
return { return {
...state, ...state,
@@ -540,9 +555,17 @@ function BadgeEditor({
} }
photoCount={data.volunteers.filter((v) => v.has_photo).length} photoCount={data.volunteers.filter((v) => v.has_photo).length}
volunteerCount={total} volunteerCount={total}
stack={stackOrder(fields, selectedField.face).map((f) => f.field_type)}
onChange={(change) => onChange={(change) =>
patch({ face: selectedField.face, type: selectedField.field_type }, 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={() => { onHide={() => {
patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false }); patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false });
setSelected(null); setSelected(null);
+58 -2
View File
@@ -1,11 +1,11 @@
import { useEffect, useState, type ReactNode } from 'react'; 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 { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout'; 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 }> = [ const TEXT_COLORS: Array<{ hex: string; key: string }> = [
{ hex: '#FFFFFF', key: 'badges.color.white' }, { hex: '#FFFFFF', key: 'badges.color.white' },
@@ -142,7 +142,9 @@ export function BadgeInspector({
effectiveFontSize, effectiveFontSize,
photoCount, photoCount,
volunteerCount, volunteerCount,
stack,
onChange, onChange,
onRestack,
onHide, onHide,
}: { }: {
field: FieldState; field: FieldState;
@@ -152,7 +154,10 @@ export function BadgeInspector({
effectiveFontSize: number | null; effectiveFontSize: number | null;
photoCount: number; photoCount: number;
volunteerCount: number; volunteerCount: number;
/** Types des champs activés de la face, du dessous vers le dessus. */
stack: BadgeFieldType[];
onChange: (patch: Partial<FieldState>) => void; onChange: (patch: Partial<FieldState>) => void;
onRestack: (move: StackMove) => void;
onHide: () => void; onHide: () => void;
}) { }) {
const isPhoto = field.field_type === 'PHOTO'; const isPhoto = field.field_type === 'PHOTO';
@@ -400,6 +405,8 @@ export function BadgeInspector({
</div> </div>
</div> </div>
<StackSection field={field} stack={stack} onRestack={onRestack} />
<button <button
type="button" type="button"
onClick={onHide} onClick={onHide}
@@ -411,3 +418,52 @@ export function BadgeInspector({
</div> </div>
); );
} }
const STACK_MOVES: Array<{ move: StackMove; key: string; atTop: boolean }> = [
{ move: 'front', key: 'badges.inspector.stack.front', atTop: true },
{ move: 'forward', key: 'badges.inspector.stack.forward', atTop: true },
{ move: 'backward', key: 'badges.inspector.stack.backward', atTop: false },
{ move: 'back', key: 'badges.inspector.stack.back', atTop: false },
];
/** Ordre de superposition du champ parmi ceux de sa face — l'ordre dans
* lequel le PDF les dessine (`BadgeField.z_order`). */
function StackSection({
field,
stack,
onRestack,
}: {
field: FieldState;
stack: BadgeFieldType[];
onRestack: (move: StackMove) => void;
}) {
const index = stack.indexOf(field.field_type);
const isTop = index === stack.length - 1;
const isBottom = index === 0;
return (
<Section
title={t('badges.inspector.stack')}
aside={t('badges.inspector.stack.position', { rank: stack.length - index, total: stack.length })}
>
<div className="grid grid-cols-2 gap-[7px]">
{STACK_MOVES.map(({ move, key, atTop }) => {
const disabled = stack.length < 2 || (atTop ? isTop : isBottom);
return (
<button
key={move}
type="button"
disabled={disabled}
onClick={() => onRestack(move)}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft disabled:cursor-default disabled:opacity-40 disabled:hover:bg-cream"
>
{t(key)}
</button>
);
})}
</div>
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
{t('badges.inspector.stack.help')}
</div>
</Section>
);
}
+55 -1
View File
@@ -67,7 +67,7 @@ export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>; type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
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 // Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80 // 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, bg_color: existing.bg_color ?? null,
auto_fit: existing.auto_fit ?? true, auto_fit: existing.auto_fit ?? true,
photo_shape: existing.photo_shape ?? 'RECT', photo_shape: existing.photo_shape ?? 'RECT',
z_order: existing.z_order ?? 0,
text: existing.text ?? null, text: existing.text ?? null,
enabled: true, enabled: true,
} }
@@ -161,6 +162,7 @@ export function fieldsFromServer(
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux /** Corps du `PUT .../badges/template/fields` : les champs activés des deux
* faces. */ * faces. */
export function toPayload(fields: FieldState[]): BadgeFieldIn[] { export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
const rank = stackRanks(fields);
return fields return fields
.filter((f) => f.enabled) .filter((f) => f.enabled)
.map((f) => ({ .map((f) => ({
@@ -177,10 +179,62 @@ export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
bg_color: f.bg_color, bg_color: f.bg_color,
auto_fit: f.auto_fit, auto_fit: f.auto_fit,
photo_shape: f.photo_shape, photo_shape: f.photo_shape,
z_order: rank.get(f) ?? 0,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null, 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<FieldState, number> {
const ranks = new Map<FieldState, number>();
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 { export function round1(value: number): number {
return Math.round(value * 10) / 10; return Math.round(value * 10) / 10;
} }