feat(billing): receipt capture — outstanding workflow, batch by check, reconciliation
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m3s

Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor"
replacement, on top of the single-movement capture from plan step 6.
No new abilities: batching and resolving are both capturing.

- outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and
  POST /billing/:id/resolve-outstanding (gated ledger:create, not
  ledger:void — resolving completes a capture rather than reversing
  one). Outstanding rows are excluded from every balance aggregate,
  matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but
  still count in the movement browser's filtered totals.
- POST /billing/batch: many customers' receipts against one check, in
  one $transaction. Deliberately not a persisted batch entity —
  checkNumber is already a column and grouping by it answers every
  legacy by-check query.
- GET /billing/by-check + a cheque-count report, replacing REPORTE
  CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print,
  PDF, CSV and XLSX come free from the existing /reportes/:slug machinery.
- Web: /estado-cuenta/lote (the Editor screen, with live reconciliation
  against the physical check amount), an "Estado de pago" filter, a
  "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura"
  nav entry.

Integration seam for the OCR auto-capture module (spec §2), which is
required to post through createBatch rather than writing Transaction
rows itself: items[i] maps to lines[i] so postedTransactionId can be
zipped back on; opts.refs[i] stamps captureRef with a duplicate-post
guard that a voided row deliberately does not block; opts.source is
service-level only, so an HTTP client cannot label hand-keyed rows as
machine-captured. captureSource/captureRef are nullable so the 40,136
migrated rows stay NULL rather than being mislabelled.

Fixes two pre-existing bugs found while building this:

- statement() filtered legacySourceTable with `notIn`, which compiles to
  SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured
  movement was invisible on the customer statement (438 rows in the
  movement browser vs 392 on the statement) while showing everywhere
  else. This would have made the whole capture feature look broken.
- The balances count query omitted the void filter its own page query
  applied, so the total disagreed with the rows.

Nav highlighting now resolves by longest match; the previous
first-startsWith logic lit up both the parent and any nested entry.

Verified end-to-end against the dev DB, API and browser; all test rows
removed afterwards. Also corrects RESUME.md, which documented the dev
ports as :3001/:3000 — they are :4501/:4500, from the env files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 21:54:41 -07:00
co-authored by Claude Opus 5
parent 26a4faa33e
commit 7df928c3ab
14 changed files with 1476 additions and 30 deletions
@@ -0,0 +1,557 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
import type {
BatchCreateInput,
BillingFacets,
ByCheckResponse,
Currency,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
/**
* Batch capture by check — the "Editor" screen from the legacy system
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
*
* Staff key many customers' receipts against ONE physical check before cutting
* it, then check that the captured total matches the check's amount. That
* reconciliation is the whole point, so the running total is the most prominent
* thing on the page and an optional "importe del cheque" field turns it into a
* live difference.
*
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
* by it answers every by-check question (see the "Reporte por cheque" report).
*/
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
interface Line {
/** Local row key — lines have no server identity until the batch posts. */
key: number;
customerId: string;
customerName: string;
amount: string;
reference: string;
period: string;
outstanding: boolean;
}
function blankLine(key: number): Line {
return {
key,
customerId: "",
customerName: "",
amount: "",
reference: "",
period: "",
outstanding: false,
};
}
export default function BatchCapturePage() {
return (
<AppShell>
<BatchCapture />
</AppShell>
);
}
function BatchCapture() {
const canCapture = useCan("ledger:create");
const [facets, setFacets] = useState<BillingFacets | null>(null);
// Check-level fields — shared by every line.
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
const [typeId, setTypeId] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
/** The physical check's amount, for reconciliation only — never submitted. */
const [checkAmount, setCheckAmount] = useState("");
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
const [nextKey, setNextKey] = useState(4);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
useEffect(() => {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const filled = lines.filter(
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
);
// Charges are captured as positive numbers and signed on submit, matching
// MovementForm — staff type what's on the bill, not a negative.
const total = useMemo(
() =>
filled
.filter((l) => !l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const outstandingTotal = useMemo(
() =>
filled
.filter((l) => l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const checkAmt = Number(checkAmount);
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
const diff = hasCheckAmt ? checkAmt - total : 0;
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
function update(key: number, patch: Partial<Line>) {
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
}
function addLine() {
setLines((ls) => [...ls, blankLine(nextKey)]);
setNextKey((k) => k + 1);
}
function removeLine(key: number) {
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
if (filled.length === 0) {
setError("Captura al menos una línea con cliente y monto.");
return;
}
const dupes = filled
.map((l) => l.customerId)
.filter((id, i, arr) => arr.indexOf(id) !== i);
if (dupes.length) {
const names = filled
.filter((l) => dupes.includes(l.customerId))
.map((l) => l.customerName);
if (
!window.confirm(
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
", ",
)}). ¿Continuar?`,
)
)
return;
}
const payload: BatchCreateInput = {
domain,
transactionDate,
checkNumber: checkNumber.trim(),
currency: currency as Currency,
typeId: typeId || undefined,
lines: filled.map((l) => ({
customerId: l.customerId,
// Every line of a check batch is a charge the office paid out.
amount: -Math.abs(Number(l.amount)),
reference: l.reference.trim() || undefined,
period: l.period.trim() || undefined,
outstanding: l.outstanding || undefined,
})),
};
setSaving(true);
setError(null);
try {
await createMovementBatch(payload);
// Re-read through the by-check view so the confirmation shows what's
// actually stored (including anything captured against this check
// earlier), not just what this request sent.
setPosted(await getByCheck(payload.checkNumber));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
} finally {
setSaving(false);
}
}
function reset() {
setPosted(null);
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
setNextKey((k) => k + 3);
setCheckNumber("");
setCheckAmount("");
}
if (!canCapture) {
return (
<div className="state-box state-error">
No tienes permiso para capturar movimientos.
</div>
);
}
if (posted) {
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Lote capturado</h1>
<p className="eyebrow">
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
{posted.count === 1 ? "movimiento" : "movimientos"}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button type="button" className="btn btn-primary" onClick={reset}>
Capturar otro cheque
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
</div>
<div className="filtered-totals" style={{ marginBottom: 16 }}>
{posted.totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span className="filtered-total-net">
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
</span>
<span>{formatNumber(t.count)} movimientos</span>
</div>
))}
{posted.outstandingCount > 0 && (
<div className="filtered-total">
<span>
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
total)
</span>
</div>
)}
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Referencia</th>
<th>Periodo</th>
<th>Estado</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{posted.items.map((i) => (
<tr key={i.id}>
<td>
<Link
href={`/estado-cuenta/${i.customerId}`}
className="inline-link"
>
{i.customerName}
</Link>
</td>
<td>{i.reference || "—"}</td>
<td>{i.period || "—"}</td>
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(i.amount, i.currency)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted" style={{ marginTop: 14 }}>
Para imprimir la conciliación, usa el reporte{" "}
<Link
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
posted.checkNumber,
)}`}
className="inline-link"
>
Reporte por cheque
</Link>
.
</p>
</>
);
}
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Captura por cheque</h1>
<p className="eyebrow">
Captura los recibos de varios clientes contra un mismo cheque y
concilia el total antes de guardar.
</p>
</div>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Datos del cheque
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
required
/>
</label>
<label className="field">
<span className="field-label">Fecha *</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Línea de negocio *</span>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Moneda *</span>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Importe del cheque</span>
<input
className="input"
type="number"
step="0.01"
min="0"
value={checkAmount}
onChange={(e) => setCheckAmount(e.target.value)}
placeholder="Para conciliar"
/>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 14,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>
Recibos ({formatNumber(filled.length)})
</h2>
<button type="button" className="btn btn-outline" onClick={addLine}>
Agregar línea
</button>
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th style={{ minWidth: 240 }}>Cliente *</th>
<th style={{ minWidth: 120 }}>Referencia</th>
<th style={{ minWidth: 100 }}>Periodo</th>
<th style={{ minWidth: 110 }} className="num">
Monto *
</th>
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
<th style={{ width: 1 }} />
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<CustomerPicker
value={l.customerId}
valueName={l.customerId ? l.customerName : undefined}
onPick={(id, name) =>
update(l.key, { customerId: id, customerName: name })
}
/>
</td>
<td>
<input
className="input"
value={l.reference}
onChange={(e) =>
update(l.key, { reference: e.target.value })
}
/>
</td>
<td>
<input
className="input"
value={l.period}
onChange={(e) => update(l.key, { period: e.target.value })}
placeholder="2026-07"
/>
</td>
<td>
<input
className="input num"
type="number"
step="0.01"
min="0"
value={l.amount}
onChange={(e) => update(l.key, { amount: e.target.value })}
placeholder="0.00"
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={l.outstanding}
onChange={(e) =>
update(l.key, { outstanding: e.target.checked })
}
aria-label="Sin fondos"
/>
</td>
<td>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => removeLine(l.key)}
disabled={lines.length === 1}
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Conciliación
</h2>
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">{currency}</span>
<span className="filtered-total-net">
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
</span>
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
</div>
{outstandingTotal > 0 && (
<div className="filtered-total">
<span>
Sin fondos{" "}
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
(no suma al cheque)
</span>
</div>
)}
{hasCheckAmt && (
<div className="filtered-total">
<span className="filtered-total-net">
{reconciled ? (
<strong className="tx-amount pos">Cuadra con el cheque</strong>
) : (
<>
Diferencia{" "}
<strong className="tx-amount neg">
{formatMoney(String(diff), currency)}
</strong>
</>
)}
</span>
</div>
)}
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={saving || filled.length === 0}
>
{saving
? "Guardando…"
: `Capturar ${formatNumber(filled.length)} ${
filled.length === 1 ? "recibo" : "recibos"
}`}
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
</form>
</>
);
}
+167 -10
View File
@@ -10,6 +10,7 @@ import {
getBillingStats,
listBalances,
listMovements,
resolveOutstanding,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
@@ -117,6 +118,8 @@ function BillingBrowser() {
const [direction, setDirection] = useState<LedgerDirection | "">("");
const [typeId, setTypeId] = useState("");
const [source, setSource] = useState("");
// "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones.
const [outstanding, setOutstanding] = useState<"" | "true" | "false">("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
@@ -126,6 +129,7 @@ function BillingBrowser() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [resolving, setResolving] = useState<MovementListItem | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -165,6 +169,7 @@ function BillingBrowser() {
direction: direction || undefined,
typeId: typeId || undefined,
source: source || undefined,
outstanding: outstanding === "" ? undefined : outstanding === "true",
from: from || undefined,
to: to || undefined,
sort: movementSort,
@@ -188,6 +193,7 @@ function BillingBrowser() {
direction,
typeId,
source,
outstanding,
from,
to,
movementSort,
@@ -304,16 +310,35 @@ function BillingBrowser() {
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
<div style={{ display: "flex", gap: 10 }}>
<Link href="/estado-cuenta/lote" className="btn btn-outline">
Captura por cheque
</Link>
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
</div>
)}
</div>
{view === "movimientos" && resolving && (
<ResolveDialog
movement={resolving}
onCancel={() => setResolving(null)}
onDone={() => {
setResolving(null);
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
)}
{view === "movimientos" && captureOpen && (
<section className="section">
<div className="section-head">
@@ -447,6 +472,21 @@ function BillingBrowser() {
</select>
</label>
<label className="filter-field">
<span className="filter-label">Estado de pago</span>
<select
className="input select"
value={outstanding}
onChange={(e) =>
setOutstanding(e.target.value as "" | "true" | "false")
}
>
<option value="">Todos</option>
<option value="true">Sin fondos (pendientes)</option>
<option value="false">Pagados</option>
</select>
</label>
<label className="filter-field">
<span className="filter-label">Desde</span>
<input
@@ -552,7 +592,9 @@ function BillingBrowser() {
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Monto</th>
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
{(canVoid || canCapture) && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>
)}
</tr>
</thead>
<tbody>
@@ -561,6 +603,8 @@ function BillingBrowser() {
key={m.id}
m={m}
canVoid={canVoid}
canCapture={canCapture}
onResolve={setResolving}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBillingStats()
@@ -799,11 +843,15 @@ function BalanceRow({
function MovementRow({
m,
canVoid,
canCapture,
onVoided,
onResolve,
}: {
m: MovementListItem;
canVoid: boolean;
canCapture: boolean;
onVoided: () => void;
onResolve: (m: MovementListItem) => void;
}) {
const [busy, setBusy] = useState(false);
@@ -854,11 +902,29 @@ function MovementRow({
</span>
<div className="tx-cur">
{m.currency} · {directionLabel(m.direction)}
{m.outstanding && !m.voided && (
<>
{" · "}
<span className="tx-outstanding">sin fondos</span>
</>
)}
</div>
</td>
{canVoid && (
{(canVoid || canCapture) && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
{/* Resolver only makes sense on a live outstanding row, and it's a
capture action (completing one), not a void. */}
{!m.voided && m.outstanding && canCapture && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => onResolve(m)}
>
Resolver
</button>
)}
{!m.voided && canVoid && (
<button
type="button"
className="btn btn-ghost"
@@ -875,6 +941,97 @@ function MovementRow({
);
}
/**
* Resolve an outstanding row: the check finally got cut. Takes the check number
* and the date it was paid, which also becomes the movement's date — the legacy
* behavior, since the ledger date is when money actually moved.
*/
function ResolveDialog({
movement,
onDone,
onCancel,
}: {
movement: MovementListItem;
onDone: () => void;
onCancel: () => void;
}) {
const [checkNumber, setCheckNumber] = useState("");
const [resolvedDate, setResolvedDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
setBusy(true);
setError(null);
try {
await resolveOutstanding(movement.id, {
checkNumber: checkNumber.trim(),
resolvedDate,
});
onDone();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo resolver el movimiento.");
setBusy(false);
}
}
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 6 }}>
Resolver movimiento sin fondos
</h2>
<p className="muted" style={{ marginBottom: 14 }}>
{movement.customerName} · {formatMoney(movement.amount, movement.currency)}{" "}
{movement.currency}
{movement.reference ? ` · ${movement.reference}` : ""}
</p>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
autoFocus
/>
</label>
<label className="field">
<span className="field-label">Fecha de pago *</span>
<input
className="input"
type="date"
required
value={resolvedDate}
onChange={(e) => setResolvedDate(e.target.value)}
/>
</label>
</div>
<p className="muted" style={{ fontSize: 13, marginTop: 10 }}>
El movimiento tomará esta fecha y empezará a contar en el saldo del
cliente.
</p>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? "Resolviendo…" : "Resolver"}
</button>
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancelar
</button>
</div>
</form>
</div>
);
}
function Pager({
page,
pageCount,
+8
View File
@@ -2102,6 +2102,14 @@ button {
color: var(--ink);
}
/* Captured-but-unpaid (legacy NOPAGO). Deliberately not the same treatment as
a voided row: the movement is real and still pending, it just doesn't count
toward the balance until a check resolves it. */
.tx-outstanding {
color: var(--warn, #b45309);
font-weight: 600;
}
/* Charges broken out by concept, with a proportional bar. `.card` carries no
padding, so the list pads itself — otherwise the total sits on the border. */
.concept-list {