diff --git a/docs/i18n-glossary.md b/docs/i18n-glossary.md index f02a13e..174a199 100644 --- a/docs/i18n-glossary.md +++ b/docs/i18n-glossary.md @@ -90,7 +90,7 @@ Keep these **as-is** across all locales: - GDPR acronym by locale: - FR = **RGPD**, ES = **RGPD**, NL = **AVG**, EN = **GDPR**, DE = **DSGVO** - Rights-text article references: keep `art. 20`, `art. 6`, etc. -- Placeholders: `{name}`, `{count}`, `{month}`, `{amount}`, `{year}`, `{date}`, `{days}`, `{sign}`, `{percent}`, `{total}`, `{label}`, `{provision}`, `{bills}`, `{frequency}`, `{version}` — keep token names **exactly**. +- Placeholders: `{name}`, `{count}`, `{month}`, `{amount}`, `{year}`, `{date}`, `{days}`, `{sign}`, `{percent}`, `{total}`, `{paid}`, `{label}`, `{provision}`, `{bills}`, `{frequency}`, `{version}` — keep token names **exactly**. - HTML-like tags inside values: ``, ``, ``, ``, ``, ``, ``, `` — keep tag names **exactly**. --- @@ -203,5 +203,6 @@ Compact labels for the charges-list frequency tag (THI-299). The full word above | 1.0 | 2026-04-20 | Initial glossary — Wave 1.5 "Opération Babel" translation. | | 1.1 | 2026-04-20 | Destructive confirmations switch to email-as-keyword pattern (§6). Drop SUPPRIMER/DELETE/LÖSCHEN/ELIMINAR. | | 1.2 | 2026-06-01 | Add `common.frequencyAbbr` abbreviation table (§4, THI-299). de-DE quarterly `Vj.`→`Quartl.` (Vorjahr collision). | +| 1.3 | 2026-07-22 | Add `{paid}` placeholder (§3, commitments stepper « X / N payées », PR #240). | Any new term, any register change, any account-name update **must** be logged here before landing in messages/\*.json. diff --git a/messages/de-DE.json b/messages/de-DE.json index 547eb1e..229a69a 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -841,8 +841,6 @@ "summarySchedule": "{paid}/{total} Raten à {amount} · letzte im {end}", "summaryOneOff": "{amount} im {month}", "progressAria": "Tilgungsfortschritt von {label}", - "markPaidAria": "Rate von {label} als bezahlt markieren", - "unmarkPaidAria": "Rate von {label} als zu zahlen markieren", "deleteAria": "{label} löschen", "toastCreated": "Verpflichtung hinzugefügt", "toastDeleted": "Verpflichtung gelöscht", @@ -852,7 +850,17 @@ "debt": "Kredit", "installmentPlan": "Ratenplan", "oneOff": "Einmalige Rechnung" - } + }, + "editFormTitle": "Verpflichtung bearbeiten", + "saveButton": "Speichern", + "saving": "Speichern…", + "cancelButton": "Abbrechen", + "editAria": "{label} bearbeiten", + "markOneAria": "Eine Rate als bezahlt markieren für {label}", + "unmarkOneAria": "Letzte bezahlte Rate rückgängig machen für {label}", + "installmentsCountAria": "{paid} von {total} Raten bezahlt für {label}", + "toastUpdated": "Verpflichtung aktualisiert", + "reductionBlocked": "Nicht möglich: {paid} Raten sind bereits markiert. Entferne zuerst Zahlungen." }, "expenses": { "title": "Meine Ausgaben", diff --git a/messages/en.json b/messages/en.json index f8b26e8..2d0b1da 100644 --- a/messages/en.json +++ b/messages/en.json @@ -841,8 +841,6 @@ "summarySchedule": "{paid}/{total} instalments of {amount} · last one in {end}", "summaryOneOff": "{amount} in {month}", "progressAria": "Repayment progress for {label}", - "markPaidAria": "Mark the {label} instalment as paid", - "unmarkPaidAria": "Mark the {label} instalment as to pay", "deleteAria": "Delete {label}", "toastCreated": "Commitment added", "toastDeleted": "Commitment deleted", @@ -852,7 +850,17 @@ "debt": "Loan", "installmentPlan": "Instalment plan", "oneOff": "One-off bill" - } + }, + "editFormTitle": "Edit commitment", + "saveButton": "Save", + "saving": "Saving…", + "cancelButton": "Cancel", + "editAria": "Edit {label}", + "markOneAria": "Mark one instalment paid for {label}", + "unmarkOneAria": "Undo the last paid instalment for {label}", + "installmentsCountAria": "{paid} of {total} instalments paid for {label}", + "toastUpdated": "Commitment updated", + "reductionBlocked": "Can’t: {paid} instalments are already ticked. Un-tick some payments first." }, "expenses": { "title": "My expenses", diff --git a/messages/es-ES.json b/messages/es-ES.json index 07b543d..bd171ad 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -841,8 +841,6 @@ "summarySchedule": "{paid}/{total} cuotas de {amount} · última en {end}", "summaryOneOff": "{amount} en {month}", "progressAria": "Progreso de amortización de {label}", - "markPaidAria": "Marcar la cuota de {label} como pagada", - "unmarkPaidAria": "Marcar la cuota de {label} como por pagar", "deleteAria": "Eliminar {label}", "toastCreated": "Compromiso añadido", "toastDeleted": "Compromiso eliminado", @@ -852,7 +850,17 @@ "debt": "Crédito", "installmentPlan": "Plan de pago", "oneOff": "Factura puntual" - } + }, + "editFormTitle": "Editar compromiso", + "saveButton": "Guardar", + "saving": "Guardando…", + "cancelButton": "Cancelar", + "editAria": "Editar {label}", + "markOneAria": "Marcar un plazo como pagado para {label}", + "unmarkOneAria": "Deshacer el último plazo pagado para {label}", + "installmentsCountAria": "{paid} de {total} plazos pagados para {label}", + "toastUpdated": "Compromiso actualizado", + "reductionBlocked": "No se puede: {paid} plazos ya están marcados. Desmarca algunos pagos primero." }, "expenses": { "title": "Mis gastos", diff --git a/messages/fr-BE.json b/messages/fr-BE.json index 71208ba..ef23d3b 100644 --- a/messages/fr-BE.json +++ b/messages/fr-BE.json @@ -841,8 +841,6 @@ "summarySchedule": "{paid}/{total} échéances de {amount} · dernière en {end}", "summaryOneOff": "{amount} en {month}", "progressAria": "Progression du remboursement de {label}", - "markPaidAria": "Marquer l'échéance de {label} comme payée", - "unmarkPaidAria": "Marquer l'échéance de {label} comme à payer", "deleteAria": "Supprimer {label}", "toastCreated": "Engagement ajouté", "toastDeleted": "Engagement supprimé", @@ -852,7 +850,17 @@ "debt": "Crédit", "installmentPlan": "Plan de paiement", "oneOff": "Facture ponctuelle" - } + }, + "editFormTitle": "Modifier l'engagement", + "saveButton": "Enregistrer", + "saving": "Enregistrement…", + "cancelButton": "Annuler", + "editAria": "Modifier {label}", + "markOneAria": "Marquer une échéance payée pour {label}", + "unmarkOneAria": "Annuler la dernière échéance payée pour {label}", + "installmentsCountAria": "{paid} sur {total} échéances payées pour {label}", + "toastUpdated": "Engagement modifié", + "reductionBlocked": "Impossible : {paid} échéances sont déjà validées. Retire d’abord des paiements." }, "expenses": { "title": "Mes dépenses", diff --git a/messages/nl-BE.json b/messages/nl-BE.json index 7b6e1c7..d701cd3 100644 --- a/messages/nl-BE.json +++ b/messages/nl-BE.json @@ -841,8 +841,6 @@ "summarySchedule": "{paid}/{total} aflossingen van {amount} · laatste in {end}", "summaryOneOff": "{amount} in {month}", "progressAria": "Aflossingsvoortgang van {label}", - "markPaidAria": "Aflossing van {label} als betaald markeren", - "unmarkPaidAria": "Aflossing van {label} als te betalen markeren", "deleteAria": "{label} verwijderen", "toastCreated": "Verbintenis toegevoegd", "toastDeleted": "Verbintenis verwijderd", @@ -852,7 +850,17 @@ "debt": "Krediet", "installmentPlan": "Afbetalingsplan", "oneOff": "Eenmalige factuur" - } + }, + "editFormTitle": "Verbintenis bewerken", + "saveButton": "Opslaan", + "saving": "Opslaan…", + "cancelButton": "Annuleren", + "editAria": "{label} bewerken", + "markOneAria": "Eén termijn als betaald markeren voor {label}", + "unmarkOneAria": "Laatste betaalde termijn ongedaan maken voor {label}", + "installmentsCountAria": "{paid} van {total} termijnen betaald voor {label}", + "toastUpdated": "Verbintenis bijgewerkt", + "reductionBlocked": "Niet mogelijk: {paid} termijnen zijn al afgevinkt. Vink eerst betalingen uit." }, "expenses": { "title": "Mijn uitgaven", diff --git a/src/app/[locale]/app/commitments/CommitmentsClient.tsx b/src/app/[locale]/app/commitments/CommitmentsClient.tsx index e8fb5a3..b3b2162 100644 --- a/src/app/[locale]/app/commitments/CommitmentsClient.tsx +++ b/src/app/[locale]/app/commitments/CommitmentsClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMemo, useOptimistic, useState, useTransition } from 'react'; -import { Check, Plus, Trash2 } from 'lucide-react'; +import { Check, Pencil, Plus, Trash2 } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; @@ -9,22 +9,25 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { toast } from '@/components/ui/toast'; +import { InstallmentStepper } from '@/components/commitments/InstallmentStepper'; import type { Locale } from '@/i18n/routing'; import { createCommitmentAction, deleteCommitmentAction, toggleCommitmentPaymentAction, + updateCommitmentAction, } from '@/lib/actions/commitments'; import { isNextControlFlowError } from '@/lib/actions/next-control-flow'; import { commitmentRowToDomain, type CommitmentRow } from '@/lib/data/commitment-row'; import { endPeriod, - installmentsPaid, - isDueInPeriod, installmentAmountOf, + installmentPeriods, + installmentsPaid, periodKey, remainingBalance, type CommitmentKind, + type Period, } from '@/lib/domain/commitments'; import { formatCurrency, formatMonth } from '@/lib/i18n/formatters'; import { useActionErrorTranslator } from '@/lib/i18n/action-errors'; @@ -40,6 +43,8 @@ type Props = { locale: Locale; }; +type FormMode = 'closed' | 'create' | 'edit'; + const KIND_KEY = { debt: 'kinds.debt', installment_plan: 'kinds.installmentPlan', @@ -56,15 +61,52 @@ export function CommitmentsClient({ const translateError = useActionErrorTranslator(); const [isPending, startTransition] = useTransition(); - const [showAddForm, setShowAddForm] = useState(false); + + // --- Form state (create + edit share one form; `formMode` is the single + // source of truth, `editingId` only meaningful in 'edit'). --- + const [formMode, setFormMode] = useState('closed'); + const [editingId, setEditingId] = useState(null); const [label, setLabel] = useState(''); const [kind, setKind] = useState('debt'); const [totalAmount, setTotalAmount] = useState(''); const [installmentAmount, setInstallmentAmount] = useState(''); const [installmentsTotal, setInstallmentsTotal] = useState('12'); - // Optimistic paid ledger: a Set of `${commitmentId}|${periodKey}` so a tick - // reflects instantly, then reconciles on the action's revalidate. + function resetForm() { + setLabel(''); + setKind('debt'); + setTotalAmount(''); + setInstallmentAmount(''); + setInstallmentsTotal('12'); + setEditingId(null); + } + + function openCreate() { + resetForm(); + setFormMode('create'); + } + + function openEdit(c: RawCommitment) { + setEditingId(c.id); + setLabel(c.label); + setKind(c.kind); + setTotalAmount(String(c.totalAmount)); + setInstallmentAmount(c.installmentAmount === null ? '' : String(c.installmentAmount)); + setInstallmentsTotal(String(c.installmentsTotal)); + setFormMode('edit'); + } + + function closeForm() { + resetForm(); + setFormMode('closed'); + } + + // Optimistic paid ledger: a Set of `${commitmentId}|${periodKey}`. The reducer + // takes an EXPLICIT intent (`{key, paid}`) rather than a blind toggle so a `+` + // always adds and a `−` always removes — idempotent even on a stale read, so a + // fast double-click can't accidentally cancel a payment. `useOptimistic` + // discards the optimistic value on settle and re-derives from `paidBase` + // (unchanged by a rejected toggle) → no manual rollback (Sourcery #234). const paidBase = useMemo(() => { const set = new Set(); for (const [id, keys] of Object.entries(paidKeysByCommitment)) { @@ -75,10 +117,10 @@ export function CommitmentsClient({ const [optimisticPaid, applyOptimisticPaid] = useOptimistic( paidBase, - (current: ReadonlySet, entry: string) => { + (current: ReadonlySet, action: { key: string; paid: boolean }) => { const next = new Set(current); - if (next.has(entry)) next.delete(entry); - else next.add(entry); + if (action.paid) next.add(action.key); + else next.delete(action.key); return next; }, ); @@ -95,19 +137,16 @@ export function CommitmentsClient({ const toDomain = commitmentRowToDomain; - function onTogglePaid(c: RawCommitment) { - const entry = `${c.id}|${periodKey(currentPeriod.year, currentPeriod.month)}`; - // No manual rollback needed on failure: `useOptimistic` discards the - // optimistic value when the transition settles and re-derives from - // `paidBase` — which the server did NOT change on a rejected toggle. A - // hand-rolled revert would double-toggle. Locked by a test (Sourcery #234). + /** Toggle ONE scheduled period, marking it paid or unpaid (explicit intent). */ + function togglePeriodPaid(c: RawCommitment, period: Period, markPaid: boolean) { + const entry = `${c.id}|${periodKey(period.year, period.month)}`; startTransition(async () => { - applyOptimisticPaid(entry); + applyOptimisticPaid({ key: entry, paid: markPaid }); try { const result = await toggleCommitmentPaymentAction({ commitmentId: c.id, - periodYear: currentPeriod.year, - periodMonth: currentPeriod.month, + periodYear: period.year, + periodMonth: period.month, }); if (result.ok) { toast.success(result.data.paid ? t('toastMarkedPaid') : t('toastMarkedUnpaid')); @@ -123,7 +162,25 @@ export function CommitmentsClient({ }); } - function onCreate(e: React.FormEvent) { + /** `+` — mark the earliest not-yet-paid scheduled instalment (fills the oldest hole). */ + function onTickNext(c: RawCommitment) { + const paidKeys = paidKeysOf(c.id); + const next = installmentPeriods(toDomain(c)).find( + (p) => !paidKeys.has(periodKey(p.year, p.month)), + ); + if (next) togglePeriodPaid(c, next, true); + } + + /** `−` — un-mark the latest paid scheduled instalment. */ + function onUntickLast(c: RawCommitment) { + const paidKeys = paidKeysOf(c.id); + const last = [...installmentPeriods(toDomain(c))] + .reverse() + .find((p) => paidKeys.has(periodKey(p.year, p.month))); + if (last) togglePeriodPaid(c, last, false); + } + + function onSubmit(e: React.FormEvent) { e.preventDefault(); const total = Number(totalAmount.replace(',', '.')); const perInstallment = Number(installmentAmount.replace(',', '.')); @@ -135,34 +192,55 @@ export function CommitmentsClient({ return; } + // Reduction guard (edit): never set installmentsTotal below what is already + // ticked — that would silently orphan out-of-schedule ledger rows and drop + // the visible paid count. Block with a friendly toast before the round-trip. + if (formMode === 'edit' && editingId && !isOneOff) { + const target = commitments.find((x) => x.id === editingId); + if (target) { + const alreadyPaid = installmentsPaid(toDomain(target), paidKeysOf(target.id)); + if (count < alreadyPaid) { + toast.error(t('reductionBlocked', { paid: alreadyPaid })); + return; + } + } + } + + // A one-off owes its total at once — one instalment, no per-instalment amount. + const payload = { + label: label.trim(), + kind, + totalAmount: total, + ...(isOneOff ? {} : { installmentAmount: perInstallment }), + installmentsTotal: isOneOff ? 1 : count, + }; + startTransition(async () => { try { - const result = await createCommitmentAction({ - label: label.trim(), - kind, - totalAmount: total, - // A one-off owes its total at once — no instalment amount. - ...(isOneOff ? {} : { installmentAmount: perInstallment }), - installmentsTotal: isOneOff ? 1 : count, - startYear: currentPeriod.year, - startMonth: currentPeriod.month, - paymentDay: 1, - frequency: 'monthly', - }); + const result = editingId + ? await updateCommitmentAction(editingId, payload) + : await createCommitmentAction({ + ...payload, + startYear: currentPeriod.year, + startMonth: currentPeriod.month, + paymentDay: 1, + frequency: 'monthly', + }); if (result.ok) { - toast.success(t('toastCreated')); - setLabel(''); - setTotalAmount(''); - setInstallmentAmount(''); - setShowAddForm(false); + toast.success(editingId ? t('toastUpdated') : t('toastCreated')); + closeForm(); } else { toast.error(translateError(result.errorCode)); } } catch (err) { if (isNextControlFlowError(err)) throw err; // eslint-disable-next-line no-console - console.error('createCommitmentAction threw', err); - toast.error(translateError('errors.commitments.createFailed')); + console.error('commitment submit threw', err); + toast.error( + translateError( + editingId ? 'errors.commitments.updateFailed' : 'errors.commitments.createFailed', + ), + ); } }); } @@ -171,8 +249,13 @@ export function CommitmentsClient({ startTransition(async () => { try { const result = await deleteCommitmentAction(id); - if (result.ok) toast.success(t('toastDeleted')); - else toast.error(translateError(result.errorCode)); + if (result.ok) { + toast.success(t('toastDeleted')); + // If the deleted row was being edited, drop the stale form. + if (editingId === id) closeForm(); + } else { + toast.error(translateError(result.errorCode)); + } } catch (err) { if (isNextControlFlowError(err)) throw err; // eslint-disable-next-line no-console @@ -187,6 +270,7 @@ export function CommitmentsClient({ (sum, c) => sum + remainingBalance(toDomain(c), paidKeysOf(c.id)), 0, ); + const isFormOpen = formMode !== 'closed'; return (
@@ -197,10 +281,10 @@ export function CommitmentsClient({
- {showAddForm && ( - + {isFormOpen && ( + - {t('addFormTitle')} + {formMode === 'edit' ? t('editFormTitle') : t('addFormTitle')} -
+
)} -
+
+
@@ -330,15 +422,10 @@ export function CommitmentsClient({ const paid = installmentsPaid(domain, paidKeys); const remaining = remainingBalance(domain, paidKeys); const end = endPeriod(domain); - const dueThisPeriod = isDueInPeriod(domain, currentPeriod); - const tickedThisPeriod = paidKeys.has( - periodKey(currentPeriod.year, currentPeriod.month), - ); - // Defensive clamp: the DB CHECK + Zod both keep - // `installmentsTotal` in [1, 600] and `installmentsPaid` can - // never exceed the schedule, so neither NaN nor >100 is - // reachable today — but corrupted data must not produce an - // invalid aria-valuenow or a bar wider than its track. + // Defensive clamp: DB CHECK + Zod keep `installmentsTotal` in + // [1, 600] and `installmentsPaid` can never exceed the schedule, + // so neither NaN nor >100 is reachable — but corrupted data must + // not produce an invalid aria-valuenow or an over-wide bar. const progress = c.installmentsTotal > 0 ? Math.min(100, Math.max(0, Math.round((paid / c.installmentsTotal) * 100))) @@ -346,11 +433,7 @@ export function CommitmentsClient({ const finished = c.installmentsTotal > 0 && paid >= c.installmentsTotal; return ( -
  • +
  • {c.label} @@ -383,9 +466,8 @@ export function CommitmentsClient({

    - {/* Progress bar — pure CSS width, no inline style beyond the - computed percentage (CSP-safe: it is a style attribute, - not an inline