# Visita y gasto avisan lo que ya está en la cola

- **Fecha:** 2026-10-01
- **Rama:** `fix/pwa-avisos-cola`
- **Origen:** seguimiento de PWA-002 ([`2026-09-28-senal-debil-design.md`](2026-09-28-senal-debil-design.md), §1.3 "Aviso nuevo en el formulario"), en producción desde main `7d68a58` (PR #268).

## Problema

Con PWA-002, un cobro, una visita "No paga" o un gasto cuya respuesta se pierde queda en la cola del teléfono y sube después. La pantalla de pago avisa si ese crédito ya tiene cobros en la cola; la visita y el gasto no:

- El modal "No paga" (`NoPaymentModal.vue`) no dice que ya hay una visita de ese crédito esperando señal.
- El formulario de gasto (`ExpenseCreateView.vue`) tampoco.
- "Mis gastos" (`MyExpensesView.vue`) lista solo lo que devuelve `/api/pwa/expenses/mine`: un gasto en la cola no aparece en ninguna parte.

Un cobrador que salió a mitad del envío y vuelve puede registrar la misma visita o el mismo gasto otra vez. Es un envío nuevo con clave nueva, así que el servidor no lo puede deduplicar; y el gasto que registra un admin se aprueba solo.

## Diseño

Las mismas reglas que el aviso de cobros (§1.3 de PWA-002):

- Cuentan las filas **propias** (`esMio`), incluidas las que se están enviando (`enEnvio`): el envío de la misma pantalla ya lo oculta su estado de envío, así que una fila en envío que se ve la mandó una pantalla que el cobrador dejó.
- Las que agotaron reintentos (`permanent_error`) no "esperan señal": se avisan aparte y mandan a Errores de sincronización.
- No bloquea: puede haber dos visitas o dos gastos reales.

### Store (`stores/sync.js`)

`ownPendingVisits` y `ownPendingExpenses`, junto a `ownPendingPayments`: la cola filtrada por `esMio`. Solo se suman getters, el motor no cambia.

### Cuándo se guardó (`utils/enCola.js`)

`cuandoSeGuardo(fila)` devuelve " de las 10:32 a. m." (" de la 1:05 p. m." a la una) si `created_at_local` es de hoy, " del 29 de sept" si es de otro día y "" sin fecha. Con la cola días sin subir, "de las 10:32" a secas haría pensar que es de hoy. Lo usan los tres avisos, también el de cobros (solo cambia esa frase). En los tres el orden es el mismo: monto, cuándo, y entre paréntesis lo que identifica el registro.

`montoReal(fila, multiplicador)`: un gasto en modo simplificado se guarda tal como se tecleó (`is_simplified_amount`, el servidor lo convierte); en pantalla va el monto real, como en Errores de sincronización.

### "No paga" (`NoPaymentModal.vue`)

Arriba del formulario, por crédito (`credit_id` del modal):

- una: "Ya hay una visita de las 10:32 (Sin dinero) guardada para este crédito, esperando señal. Si es la misma, no la registres otra vez."
- varias: "Ya hay N visitas guardadas para este crédito, esperando señal. Si alguna es esta misma, no la registres otra vez."
- con error: "1 visita de este crédito no se pudo subir: revísala en Errores de sincronización antes de registrar otra." / "N visitas de este crédito no se pudieron subir: revísalas en Errores de sincronización antes de registrar otra."

El motivo reemplaza al monto del aviso de cobros: es lo que identifica la visita. Se oculta mientras se envía y con el modal cerrado: tras guardar, el modal se cierra con una transición y no debe mostrar la fila recién guardada. Al abrirse, el modal relee la cola (`preloadOfflineData`), como `PaymentView` con `loadPendingPayments`.

### Gasto (`ExpenseCreateView.vue`)

Arriba del formulario, de todos los gastos propios en la cola (un gasto no tiene crédito que lo acote):

- uno: "Ya hay un gasto de $5.000 de las 10:32 a. m. (Transporte y viáticos) guardado en el teléfono, esperando señal. Si es el mismo, no lo registres otra vez."
- varios: "Ya hay N gastos guardados en el teléfono ($X en total), esperando señal. Si alguno es este mismo, no lo registres otra vez."
- con error: "1 gasto no se pudo subir: revísalo en Errores de sincronización antes de registrar otro." / "N gastos no se pudieron subir: revísalos en Errores de sincronización antes de registrar otro."

Se oculta mientras `submitting`, que tras un registro sigue en verdadero hasta el regreso automático (el patrón `entregado` del cobro). Al montarse, el formulario relee la cola.

**Categorías** (`utils/expenseCategories.js`): los botones conservan sus nombres cortos. El aviso y "Mis gastos" usan la etiqueta del servidor, la que tiene el gasto una vez subido ("Transporte y viáticos"), para que no cambie de nombre al subir. Se traen de `/api/pwa/expenses/categories` una vez por sesión, con señal y con token (sin token sería un 401 y el interceptor recargaría en el login), y se guardan en `localStorage`. Sin red se usan las guardadas, y si no hay ninguna, el nombre del botón.

### "Mis gastos" (`MyExpensesView.vue`)

- **Arriba, los gastos propios de la cola**, del más nuevo al más viejo, con la marca "En el teléfono, sin subir" y una línea que aclara que todavía no están en el total. No se filtran por el período: lo que no subió importa sea del día que sea. Los que agotaron reintentos llevan además "No se pudo subir: revísalo en Errores de sincronización", con enlace.
- **Sin duplicar lo que el servidor ya tiene.** Si la respuesta se perdió, el gasto está en el servidor y en la cola a la vez hasta la próxima ronda. `/api/pwa/expenses/mine` pasa a devolver `idempotency_key`, y una fila de la cola cuya clave ya está en la lista del servidor no se muestra como "sin subir": ya subió.
- **Al entrar se relee la cola** (`preloadOfflineData`, como Errores de sincronización).
- **Cuando un gasto sale de la cola** (lo subió el motor), la lista del servidor se recarga sin spinner. Hasta que llega una lista que lo trae, el gasto sigue arriba con la marca "Ya subió": si la recarga falla (la señal se fue otra vez) o el gasto es de otro período, saldría de la cola sin estar en la lista y desaparecería de la pantalla, justo lo que invita a cargarlo otra vez. No cuentan los que salen con `permanent_error` (el motor no los sube: si salen, se descartaron) ni los que la lista ya traía.
- **Si la lista del servidor no carga** (sin señal), la pantalla dice "No se pudieron cargar los gastos que ya subieron." con "Reintentar" y oculta el resumen, en vez de "Sin gastos en este período" con totales en cero: sin señal es justo cuando el cobrador mira la cola, y "sin gastos" lo invitaría a registrar de nuevo lo que ya subió antes. Solo la última carga pedida escribe la lista.
- **Limitación conocida:** el emparejamiento por clave solo ve la lista del período elegido (`/mine` filtra por `operation_date` y trae hasta 200). Un gasto con la respuesta perdida y fecha anterior al período se ve como "sin subir" hasta que la siguiente ronda lo suelte. No se resuelve: dura minutos y no lleva a duplicar (la fila sigue en la cola y el servidor la deduplica por la clave).

## Pruebas

**PHP:** `mis_gastos` devuelve la `idempotency_key` de cada gasto.

**E2E**, pasos dentro del único test con login de `senal-debil.spec.js`:

- Visita con la respuesta perdida: reabrir "No paga" en ese crédito muestra el aviso.
- Gasto con la respuesta perdida: reabrir el formulario muestra el aviso con el monto que registró el servidor. En "Mis gastos" el gasto aparece una sola vez y sin la marca "sin subir" (el servidor ya lo tiene), con la fila todavía en la cola.
- Gasto sin respuesta: en "Mis gastos" aparece con "En el teléfono, sin subir". Tras la ronda, con la recarga de la lista fallando, sale de la cola y sigue a la vista con "Ya subió"; una lista que lo trae lo reemplaza por la tarjeta del servidor.
- Filas inyectadas: una en modo simplificado (`amount: 2.5`) y otra que agotó reintentos. La tarjeta y el aviso muestran el monto real (2,5 × el multiplicador); "Mis gastos" y el formulario dicen que la otra no se pudo subir. El demo arranca sin modo simplificado, así que solo esta fila fija las unidades en CI. Las filas se borran al terminar el paso.
- Mientras un paso mira la cola, los lotes del motor quedan frenados (`retenerLotes`): la ronda automática (cada 3 minutos, al volver la señal) podría subir la fila en medio.
- Antes de quitar el corte del envío directo, el paso comprueba que el corte atajó ESE envío (`visto.cuerpos`), y el de "sin respuesta" espera a que se vaya el aviso "Gasto guardado en el teléfono" del paso anterior: si no, ese aviso (4 s) daba por visto el nuevo antes del POST y el gasto llegaba al servidor.

Cada paso se comprobó fallando con su arreglo quitado: sin `montoReal` en el aviso o en la tarjeta, sin emparejar por clave, sin la recarga al vaciarse la cola y sin conservar los recién subidos.

## Fuera de alcance

- Una pantalla de visitas en la PWA.
- Deduplicar en el servidor un segundo registro con otra clave (el lote de cobros ya retiene posibles duplicados; visitas y gastos no tienen esa regla).
