Mode de paiement de la cotisation visible (en ligne, sur place)
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

Jusqu'ici seul un paiement en ligne apparaissait ; un bénévole qui paiera
sur place ne se distinguait pas d'un bénévole sans cotisation. Mode déduit
des champs existants (lib/fee.ts:feeMode, exporting/xlsx.py:_payment_mode),
sans migration :
- fiche : section « Cotisation » toujours visible — paiement en ligne
  (type, montant, statut, mécénat et adresse du reçu) ou case « Paiera sur
  place » modifiable à la main
- liste : colonne « Cotisation » (Sur place / En ligne ✓ / Mécénat ✓ / —)
- export Excel : colonne « Mode de paiement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-03 18:47:06 +02:00
co-authored by Claude Opus 5.5
parent a6e7b6e7fb
commit ff57bbd863
6 changed files with 167 additions and 22 deletions
+104 -20
View File
@@ -37,6 +37,7 @@ import type {
} from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { feeMode } from '@/lib/fee';
import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time';
@@ -188,14 +189,9 @@ function PublicFormAnswers({
const hasAnything =
volunteer.has_photo ||
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available ||
volunteer.fee_payment_status !== null;
volunteer.self_declared_available;
if (!hasAnything) return null;
const feeStatusKey = volunteer.fee_payment_status
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
: null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
@@ -232,20 +228,75 @@ function PublicFormAnswers({
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
{feeStatusKey ? (
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
{t(feeStatusKey)}
</span>
) : null}
</div>
</div>
);
}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
{volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
/**
* Cotisation du bénévole (voir `lib/fee.ts:feeMode`) : paiement en ligne
* (type, montant, statut, mécénat), paiement sur place, ou rien. Toujours
* affichée, pour qu'un « rien » se voie aussi ; le paiement sur place reste
* modifiable à la main (bénévole ajouté sans le formulaire, changement
* d'avis…), un paiement en ligne non — son statut vient de SumUp.
*/
function FeeSection({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const mode = feeMode(volunteer);
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>{t('config.vol.fee.title')}</FieldLabel>
{mode === 'ONLINE' && volunteer.fee_payment_status ? (
<>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="border-2 border-ink bg-blue px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.online')}
</span>
{volunteer.mecenat_address ? (
<span className="border-2 border-ink bg-mauve px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.mecenat')}
</span>
) : null}
<span
className={cx(
'border-2 border-ink px-[8px] py-[3px] font-mono text-[10.5px] font-bold',
volunteer.fee_payment_status === 'PAID' ? 'bg-green text-cream' : 'bg-yellow-soft',
)}
>
{t(FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status])}
</span>
</div>
{volunteer.fee_payment_label ? (
<p className="font-mono text-[11px]">
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
</p>
) : null}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
{volunteer.mecenat_postal_code} {volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
</>
) : (
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px] font-bold">{t('config.vol.fee.onSite')}</span>
{mode === 'NONE' ? (
<span className="font-mono text-[10px] opacity-60">— {t('config.vol.fee.none')}</span>
) : null}
</label>
)}
</div>
);
}
@@ -483,6 +534,35 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
return out;
}
const LIST_COLUMNS =
'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.2fr) minmax(0,1.2fr)';
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
* d'un paiement en ligne non abouti. */
function FeeTag({ volunteer }: { volunteer: VolunteerRead }) {
const mode = feeMode(volunteer);
if (mode === 'NONE') return <span className="font-mono text-[10px] opacity-40">—</span>;
if (mode === 'ON_SITE') {
return (
<span className="truncate font-mono text-[10px] font-bold">{t('config.vol.fee.onSiteShort')}</span>
);
}
const paid = volunteer.fee_payment_status === 'PAID';
return (
<span className={cx('truncate font-mono text-[10px] font-bold', paid ? 'text-green' : 'text-orange-dark')}>
{t(volunteer.mecenat_address ? 'config.vol.fee.mecenatShort' : 'config.vol.fee.onlineShort')}
{paid ? ' ✓' : ` · ${t(FEE_STATUS_SHORT_KEY[volunteer.fee_payment_status ?? 'PENDING'])}`}
</span>
);
}
const FEE_STATUS_SHORT_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.fee.short.pending',
PAID: 'config.vol.fee.short.paid',
FAILED: 'config.vol.fee.short.failed',
EXPIRED: 'config.vol.fee.short.expired',
};
export function ConfigVolunteers() {
const volunteers = useVolunteers();
const artists = useArtists();
@@ -641,12 +721,13 @@ export function ConfigVolunteers() {
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected?.id;
@@ -659,7 +740,7 @@ export function ConfigVolunteers() {
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
@@ -684,6 +765,7 @@ export function ConfigVolunteers() {
volunteer.wished_companion_ids.length,
})}
</span>
<FeeTag volunteer={volunteer} />
</button>
);
})}
@@ -784,6 +866,8 @@ export function ConfigVolunteers() {
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<FeeSection volunteer={selected} patch={patch} />
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />