Skip to content

Listados de trabajo: /patients, /budgets, /invoices, /payments ​

Status: draft — design plan aprobado 2026-05-14. Plan técnico en docs/technical/lists-redesign.md. Contrato cross-módulo en docs/technical/payments/cross-module-summaries.md.

Problema actual ​

Los cuatro listados centrales de la clínica funcionan hoy como vistas de inventario, no como herramientas de trabajo:

  • /patients muestra nombre + teléfono + estado. Nada de ciudad, deuda, idioma, última visita ni "no contactar". Búsqueda por texto, sin filtros.
  • /budgets muestra número + paciente + total + fecha + estado del presupuesto. Cero información de cobro — la dentista no sabe qué presupuesto está pagado y cuál no salvo abriendo uno por uno.
  • /invoices ya tiene varios filtros (estado, vencidas, compliance). Falta rango de fechas en la barra principal, serie, orden, y la barra ocupa 4 líneas en móvil.
  • /payments usa el peor patrón: cuatro UFormField en grid + text input pidiendo patient_id literal (UUID). Sin paginación visible, sin orden, sin chips.

Todos esconden columnas en móvil con hidden sm:inline en vez de re-componer la fila como card legible.

A nivel de código, cada listado reescribe lógica de paginación + debounce + watch + URLSearchParams (≈100 líneas repetidas por página). El estado vive en ref() locales: refrescar la página descarta filtros, los links no son compartibles.

Propuesta ​

Una capa de primitivos compartidos (DataListLayout, FilterBar, useListQuery, ...) en host frontend que los cuatro listados consumen. Sobre esa capa, datos nuevos por listado y filtros que el día a día de la clínica necesita.

ListadoInformación nuevaFiltros nuevos
/patientsAvatar · ciudad · deuda (slot payments)Estado · ciudad · con deuda > 0 · no contactar
/budgetsAvatar paciente · cobrado/pendiente (slot payments) · "vence en Nd"Estado de cobro (pagado/parcial/sin cobro) · profesional · vencimiento · rango fechas
/invoicesSerie · "vencida Nd" · pendiente más visibleRango fechas · serie · sólo notas de crédito · rango importe
/paymentsMétodo con icono · asignación visual desglosada · avatar pacienteMétodo (chips) · presets fecha · sin asignar · con reembolsos · paciente (autocomplete)

Cross-módulo (deuda en /patients, cobro en /budgets) vía slots + endpoints del módulo payments — el patrón que ya conecta BudgetPaymentsCard con budget.detail.sidebar y PatientPaymentsPanel con patient.detail.administracion.payments. Cero cambios en manifest.depends.

Por qué ahora ​

  • La recepcionista necesita responder "¿quién me debe dinero?" sin abrir paciente por paciente.
  • La dentista necesita "¿qué presupuestos aceptados están sin cobrar?" sin abrir uno por uno.
  • Administración necesita "¿qué facturas están vencidas en serie A este trimestre?".
  • En móvil, una fila tiene hoy 6 valores apretados — inviable en tareas reales.
  • Los primitivos parciales que ya existen (ListRow, SearchBar, PaginationBar, EmptyState, FilterChip, StatusBadge, Money, PageHeader) están maduros. Buen momento para consolidar la capa que falta sin tocar la base.

Reglas duras ​

  • Off-books rule. Cero comparaciones entre eje "facturado" (Invoice) y eje "pagado" (Payment) en cualquier listado. Deuda del paciente = clinic_receivable del ledger = total_earned − net_paid. Estado de cobro del presupuesto = Σ allocations a este budget / budget.total_with_tax. Ambos son eje pagos puro. Ver ADR 0010 y payments/CLAUDE.md gotchas.
  • Aislamiento de módulos. Nada importa fuera de su manifest.depends. Enriquecimiento cross-módulo via slots (frontend) + endpoints del módulo origen (backend). Cero cambios de manifest.
  • Mobile-first. Card real en <md, tap targets ≥44 px, filtros en slide-over si pasan de 3 chips visibles.
  • URL como fuente de verdad para filtros + page + sort. Pegar un link reproduce la vista exacta. Back/forward del navegador funcionan.
  • do_not_contact se respeta: el filtro existe y se aplica como tri-state (todos / sólo contactables / sólo no contactar).

/patients ​

Columnas (desktop row) ​

FieldNotas
1AvatarIniciales last_name[0] + first_name[0] sobre UAvatar. Color por hash estable.
2Nombrelast_name, first_name. Línea principal.
3Ciudadaddress.city. Subtitle. Vacío → "—".
4Contacto`phone
5DeudaBadge € X.XX rojo si >0, gris si =0, oculto si paciente sin actividad económica. Slot patients.list.row.financial rellenado por payments.
6EstadoStatusBadge active / archived.
7No contactarIcono i-lucide-bell-off warning si do_not_contact=true. Tooltip "No contactar".

Filtros visibles (chips en FilterBar) ​

  • Estado (FilterChipMulti — active/archived, default: active)
  • Ciudad (FilterEntityPicker async sobre lista distinct de ciudades del clinic; vacío = todas)
  • Con deuda > 0 (FilterToggle, slot patients.list.filter rellenado por payments)
  • No contactar (tri-state)

Filtros avanzados (popover "Más filtros") ​

  • Rango de edad (slider min/max derivado de date_of_birth)
  • Idioma preferido (preferred_language)
  • Fecha de registro (FilterDateRange)
  • Profesión (profession, autocomplete distinct)

Orden (SortMenu) ​

  • Nombre (asc/desc, default)
  • Fecha registro (más recientes / más antiguos)
  • Deuda (mayor / menor — sólo si filtro "con deuda" activo, server-side por patient_id set)

Vista móvil (<md) ​

┌─────────────────────────────────────────┐
│ [A] Pérez Martínez, Lucía       [⛔]    │
│     Madrid · 600 555 333                │
│     Deuda: 342,50 €      [active]       │
└─────────────────────────────────────────┘

Card con avatar grande, deuda como segunda fila prominente (la métrica de trabajo), badges a la derecha.

/budgets ​

Columnas (desktop row) ​

FieldNotas
1Avatar paciente + NombreIniciales + last_name, first_name.
2Nº presupuestobudget_number · v{version}, tnum.
3EstadoBudgetStatusBadge (draft/sent/accepted/rejected/expired/cancelled).
4TotalMoney strong.
5CobroMini-barra cobrado / total + chip Pagado · Parcial · Sin cobro. Slot budget.list.row.payments rellenado por payments. Oculto si presupuesto en draft.
6Profesional asignadoAvatar mini si presente.
7Válido hastaFecha o badge "Vence en 3d" warning / "Vencido" danger.

Filtros visibles ​

  • Estado (FilterChipMulti — draft / sent / accepted / ...)
  • Cobro (FilterChipMulti — Pagado / Parcial / Sin cobro, slot budget.list.filter rellenado por payments)
  • Profesional (FilterEntityPicker — usuarios con rol dentist/hygienist del clinic)
  • Vencimiento (FilterChipMulti — Vigente / Vence en 7d / Vencido) — preset puro frontend
  • Búsqueda libre (número, paciente)

Filtros avanzados ​

  • Rango fechas creación
  • Rango importe total (€)
  • Paciente específico (autocomplete)

Orden ​

  • Fecha creación (default desc)
  • Válido hasta
  • Total
  • Estado de cobro (sólo si filtro "Cobro" activo)

Móvil ​

┌─────────────────────────────────────────┐
│ [A] García López, Juan                  │
│     PRES-2025-0184 · v2  [accepted]     │
│     1.840,00 €   ████░░░░  Parcial      │
│     Dra. Ruiz · Vence en 5d             │
└─────────────────────────────────────────┘

/invoices ​

Columnas (desktop row) ​

FieldNotas
1Nºinvoice_number o "Borrador".
2SerieSi presente series.code.
3EstadoBadge (draft/issued/partial/paid/cancelled/voided).
4ComplianceSlot invoice.list.row.meta existente (verifactu, factur-x).
5Emisiónissue_date.
6VencimientoFecha. Si vencida: rojo + "Vencida Nd" debajo.
7Pacientelast_name, first_name.
8TotalMoney strong.
9Pendientebalance_due warning, sólo si > 0 y status ≠ draft.

Filtros visibles ​

  • Estado (multi-select)
  • Vencidas (toggle, ya existe)
  • Compliance severity (slot invoice.list.toolbar.filters existente)
  • Fechas emisión (FilterDateRange con presets) — nuevo en barra principal
  • Búsqueda libre

Filtros avanzados ​

  • Rango vencimiento (due_from / due_to — ya en API)
  • Serie (FilterEntityPicker con InvoiceSeries)
  • Paciente
  • Sólo notas de crédito (is_credit_note=true)
  • Rango importe

Orden ​

  • Emisión (default desc)
  • Vencimiento
  • Total
  • Pendiente

Móvil ​

┌─────────────────────────────────────────┐
│ FAC-A-2025-0091          [paid]    [✓]  │
│ Ruiz Sánchez, Pedro                     │
│ Emitida 12 abr · Vencía 12 may          │
│ 1.230,00 €                              │
└─────────────────────────────────────────┘

Compliance badge inline al lado del estado (ya soportado).

/payments ​

Columnas (desktop row) ​

FieldNotas
1Fechapayment_date.
2Avatar paciente + Nombre
3MétodoIcono por método + label.
4ImporteMoney strong.
5AsignaciónDesglose visual: 1.000 € presupuesto + 200 € a cuenta con icons.
6ReembolsadoBadge rojo −X € si refunded_total > 0.
7Referenciareference truncado, mono.

Filtros visibles ​

  • Método (FilterChipMulti con icons — efectivo, tarjeta, transferencia, ...)
  • Fechas (FilterDateRange con presets: Hoy / 7d / 30d / Este mes / Trimestre / Año)
  • Con reembolsos (FilterToggle)
  • Sin asignar (FilterToggle — has_unallocated=true)
  • Paciente (FilterEntityPicker autocomplete — sustituye al text input de UUID)

Filtros avanzados ​

  • Rango importe
  • Presupuesto específico (autocomplete)

Orden ​

  • Fecha (default desc)
  • Importe

Móvil ​

┌─────────────────────────────────────────┐
│ [💳] Ruiz Sánchez, Pedro     1.200 €    │
│      12 may 2026 · Tarjeta · ref XYZ    │
│      1.000 € presupuesto + 200 € cta    │
└─────────────────────────────────────────┘

Primitivos compartidos ​

Capa nueva en host frontend/app/components/shared/ y app/composables/. Los detalles de props/contratos viven en el plan técnico. Vista panorámica:

PrimitivoReemplaza
DataListLayout.vueWrappers manuales (<UCard> + estados + paginación) en las 4 páginas
DataListItem.vueEsconder-columnas en móvil; render dual row/card
FilterBar.vue<div class="flex flex-wrap gap-..."> con UInput+USelectMenu hand-rolled
FilterChipMulti.vueUSelectMenu multiple con label "Estado" inerte
FilterDateRange.vueDos UInput type="date" separados
FilterToggle.vueUCheckbox plano
FilterEntityPicker.vueText input pidiendo UUID literal (payments hoy)
SortMenu.vueInexistente: ningún listado tiene orden hoy
useListQuery<F>()Tres veces el patrón searchTimeout + watch + URLSearchParams

Mobile-first ​

  • DataListItem renderiza row slot en ≥md, card slot en <md. Switch en CSS (@container o md: Tailwind) — sin remontar.
  • Tap targets ≥44 px (min-h-[var(--density-row-height)] ya existe en ListRow).
  • FilterBar colapsa: ≤3 chips visibles + botón "Filtros (Nactivos)" que abre USlideover con todos.
  • Cero tooltips — no funcionan táctil. La info crítica está en el card.

URL como fuente de verdad ​

useListQuery sincroniza filtros↔URL en ambos sentidos:

/budgets?status=accepted,sent&payment_status=partial,unpaid&page=2&sort=valid_until:asc
  • Filtros simples → ?status=draft,accepted
  • Booleanos → ?overdue=1
  • Date ranges → ?date_from=2026-01-01&date_to=2026-03-31 o preset ?date_preset=this_quarter
  • Cross-módulo (filtros slot) → param propio (?with_debt=1, ?payment_status=unpaid) que el composable convierte en llamada al endpoint payments + ?budget_ids[]= / ?patient_ids[]= sobre el listado nativo.

Back/forward del navegador funcionan. Compartir el link reproduce la vista. Zero storage.

Aislamiento de módulos ​

Host listadoSlot exponeProvider rellenaPermiso
patientspatients.list.row.financial (ctx { patient_id, summary })paymentspayments.record.read
patientspatients.list.filter (ctx { value, onChange })paymentspayments.record.read
budgetbudget.list.row.payments (ctx { budget_id, summary })paymentspayments.record.read
budgetbudget.list.filter (ctx { value, onChange })paymentspayments.record.read
billinginvoice.list.row.meta ya existe (compliance)verifactu / factur-xvaría
billinginvoice.list.toolbar.filters ya existeverifactu / factur-xvaría
  • patients y budget NUNCA importan código de payments. El registro se hace via frontend/plugins/slots.client.ts del módulo payments.
  • payments.depends = ["patients", "budget"] — legal importar tipos/conceptos de ambos.
  • billing.depends = ["patients", "catalog", "budget", "payments"] — sin cambios.
  • Endpoints nuevos del cross-módulo todos en /api/v1/payments/... (ver contrato).

Permisos ​

AcciónPermissionSi falta
Ver listado /patientspatients.read403
Ver columna deuda + filtro "con deuda"payments.record.readSlot resuelve vacío → no se renderiza nada (ni columna ni filtro). Listado funciona sin la enriquecimiento.
Ver listado /budgetsbudget.read403
Ver mini-progreso + filtro "Cobro"payments.record.readIgual: slot vacío, listado nativo intacto.
Ver listado /invoicesbilling.read403
Ver listado /paymentspayments.record.read403

Off-books safeguard (revisión obligatoria en PR) ​

Tres invariantes que el reviewer comprueba:

  1. Deuda del paciente = ledger clinic_receivable, calculado en payments/service.py:LedgerService.get_patient_ledger como max(0, total_earned − net_paid). NO se calcula nunca como Σ invoice.balance_due y NO se compara contra ningún campo de billing.
  2. Estado de cobro del presupuesto = Σ allocations a este budget / budget.total_with_tax, derivado en payments. NO se compara con facturas emitidas del mismo presupuesto.
  3. Listados de invoices y payments siguen separados. Cero fila que mezcle ambos ejes en la misma row, cero KPI agregado que muestre la diferencia.

Test backend asegura que clinic_receivable ≠ Σ invoice.balance_due cuando hay tratamientos earned sin invoice (caso típico off-books).

Cómo se valida ​

  1. docker-compose up + ./scripts/reset-db.sh && ./scripts/seed-demo.sh. Login admin@demo.clinic.
  2. Smoke en cada listado: columnas nuevas visibles, filtros aplicables, URL refleja estado.
  3. Cross-módulo: en /budgets, ver mini-progress por fila; aplicar "Cobro: Sin pagar" → URL incluye ?payment_status=unpaid y la lista filtra. Idéntico en /patients con "Con deuda".
  4. Off-books guard: crear paciente con tratamiento performed (earned > 0) sin invoice → /patients muestra deuda en la fila, /invoices del paciente queda vacío, ningún listado mezcla los ejes.
  5. Permission gating: revocar payments.record.read a un usuario → /patients y /budgets renderizan sin la columna nueva ni el filtro; los listados nativos intactos.
  6. Módulo desinstalado (payments es removable=False, pero permission-denied es la práctica): mismo comportamiento que gating.
  7. Mobile: viewport 375 × 667 — cards visibles, filtros colapsados en slide-over, tap-target medidos.
  8. URL state: pegar URL con filtros + sort + page en otra pestaña, listado abre con el mismo estado. Back/forward conservan filtros.
  9. Aislamiento: rg "from app.modules.billing" backend/app/modules/payments/ → 0. Idéntico para from app.modules.payments dentro de backend/app/modules/patients/ y backend/app/modules/budget/.

Lo que esta feature NO añade ​

  • Vistas guardadas por usuario (modelo SavedView). URL-only cubre 90% del valor; vistas guardadas se valoran como fase 2.
  • Bulk actions (multi-select de filas + acción masiva).
  • Swipe-actions en card móvil (delete/archive).
  • Tags/segmentos custom de pacientes (VIP, aseguradora, ...).
  • Slot last_visit desde schedules — fuera salvo que el módulo schedules ya exponga la query.
  • Exportar CSV.
  • Sort por column-header clickable — sustituido por SortMenu dropdown por consistencia móvil.
  • Manifest changes. Ninguno.
  • Permisos nuevos. Ninguno.
  • Migraciones. Ninguna.