Badges : photo du bénévole et texte libre
Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024), repris dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5b493c9737
commit
54cba467fc
@@ -289,6 +289,7 @@ export function buildMockState(): MockState {
|
||||
wished_companion_ids,
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -234,6 +234,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
wished_companion_ids: [],
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
badge_text: null,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
@@ -995,6 +996,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1039,6 +1041,7 @@ export function createMockApi(): PlanningApi {
|
||||
...('companion_wish_note' in body
|
||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||
: {}),
|
||||
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
|
||||
...('self_declared_available' in body && body.self_declared_available != null
|
||||
? { self_declared_available: body.self_declared_available }
|
||||
: {}),
|
||||
@@ -1302,6 +1305,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1772,6 +1776,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
badge_text: null,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -107,6 +107,9 @@ export interface VolunteerRead {
|
||||
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||
has_photo: boolean;
|
||||
companion_wish_note: string | null;
|
||||
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||
* coordination. */
|
||||
badge_text: string | null;
|
||||
self_declared_available: boolean;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
@@ -157,6 +160,7 @@ export interface VolunteerUpdate {
|
||||
wished_position_ids?: string[] | null;
|
||||
wished_companion_ids?: string[] | null;
|
||||
companion_wish_note?: string | null;
|
||||
badge_text?: string | null;
|
||||
self_declared_available?: boolean | null;
|
||||
willing_to_pay_fee?: boolean | null;
|
||||
}
|
||||
@@ -419,7 +423,15 @@ export interface MatrixResponse {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Badges (planches PDF) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
|
||||
/** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre
|
||||
* de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */
|
||||
export type BadgeFieldType =
|
||||
| 'FULL_NAME'
|
||||
| 'POSITIONS'
|
||||
| 'CATEGORY'
|
||||
| 'ROLE'
|
||||
| 'PHOTO'
|
||||
| 'FREE_TEXT';
|
||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||
|
||||
export interface BadgeFieldIn {
|
||||
|
||||
@@ -230,6 +230,8 @@
|
||||
"config.vol.field.email": "Email (lien magique)",
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
|
||||
"config.vol.access.title": "Accès de connexion",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
|
||||
"config.vol.access.grant": "Donner un accès de connexion",
|
||||
@@ -340,7 +342,7 @@
|
||||
"badges.tab.customize": "PERSONNALISER",
|
||||
"badges.tab.generate": "GÉNÉRER",
|
||||
"badges.front": "Recto",
|
||||
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
|
||||
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
|
||||
"badges.back": "Verso",
|
||||
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
|
||||
"badges.fields": "Champs du recto",
|
||||
@@ -352,6 +354,12 @@
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
"badges.field.role.placeholder": "Référent",
|
||||
"badges.field.photo": "Photo du bénévole",
|
||||
"badges.field.photo.placeholder": "Photo",
|
||||
"badges.field.photo.help": "La photo envoyée avec le formulaire d'inscription, recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||
"badges.field.freeText": "Texte libre",
|
||||
"badges.field.freeText.placeholder": "Accès backstage",
|
||||
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
|
||||
"badges.field.size": "Taille",
|
||||
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||
"badges.size.width": "Largeur du badge (mm)",
|
||||
|
||||
@@ -45,13 +45,29 @@ function useEditorScale(badgeWidthMm: number): number {
|
||||
|
||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
|
||||
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
|
||||
const FIELD_TYPES: BadgeFieldType[] = [
|
||||
'FULL_NAME',
|
||||
'POSITIONS',
|
||||
'CATEGORY',
|
||||
'ROLE',
|
||||
'PHOTO',
|
||||
'FREE_TEXT',
|
||||
];
|
||||
|
||||
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName',
|
||||
POSITIONS: 'badges.field.positions',
|
||||
CATEGORY: 'badges.field.category',
|
||||
ROLE: 'badges.field.role',
|
||||
PHOTO: 'badges.field.photo',
|
||||
FREE_TEXT: 'badges.field.freeText',
|
||||
};
|
||||
|
||||
// Indication affichée à côté du nom du champ, pour ceux dont l'origine du
|
||||
// contenu n'est pas évidente.
|
||||
const FIELD_HELP_KEY: Partial<Record<BadgeFieldType, string>> = {
|
||||
PHOTO: 'badges.field.photo.help',
|
||||
FREE_TEXT: 'badges.field.freeText.help',
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
@@ -59,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
POSITIONS: 'badges.field.positions.placeholder',
|
||||
CATEGORY: 'badges.field.category.placeholder',
|
||||
ROLE: 'badges.field.role.placeholder',
|
||||
PHOTO: 'badges.field.photo.placeholder',
|
||||
FREE_TEXT: 'badges.field.freeText.placeholder',
|
||||
};
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
@@ -83,6 +101,16 @@ const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> =
|
||||
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||||
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
|
||||
},
|
||||
// Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite.
|
||||
// Taille de police/couleur sans effet : la photo est une image.
|
||||
PHOTO: {
|
||||
x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28,
|
||||
font_size: 8, align: 'CENTER', bold: false, color: '#000000',
|
||||
},
|
||||
FREE_TEXT: {
|
||||
x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7,
|
||||
font_size: 9, align: 'LEFT', bold: true, color: '#000000',
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
@@ -445,18 +473,24 @@ function FieldBox({
|
||||
height: field.height_mm * scale,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
color: field.color,
|
||||
width: '100%',
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
{field.field_type === 'PHOTO' ? (
|
||||
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
|
||||
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
color: field.color,
|
||||
width: '100%',
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResizeMove}
|
||||
@@ -482,19 +516,26 @@ function FieldEditorRow({
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
||||
<label className="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</label>
|
||||
{FIELD_HELP_KEY[type] ? (
|
||||
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{field.enabled ? (
|
||||
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
|
||||
(sur l'aperçu) comptent. */}
|
||||
{field.enabled && type !== 'PHOTO' ? (
|
||||
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||
<label className="flex items-center gap-[5px]">
|
||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||
|
||||
@@ -884,6 +884,16 @@ export function ConfigVolunteers() {
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>
|
||||
{t('config.vol.field.badgeText')}
|
||||
<HelpTip text={t('config.vol.field.badgeText.help')} className="ml-[6px]" />
|
||||
</FieldLabel>
|
||||
<TextInput
|
||||
value={selected.badge_text ?? ''}
|
||||
onChange={(v) => patch({ badge_text: v || null })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||
|
||||
Reference in New Issue
Block a user