Agenda de clientes

Tutorial paso a paso sobre el módulo Agenda de clientes en Despiece: la CRM mínima del taller para reusar datos de clientes recurrentes (nombre, teléfono, email, CUIT, dirección) entre proyectos, integrada con el sheet [C] Cliente y orden. El módulo vive en dos paneles: el sheet [K] para administrar la agenda y el sheet [C] para usarla mientras armás un proyecto.

Los datos viven en IDB despiece-clients v2 con un único object store clients. Workshop-global, no per-project (igual que el catálogo de herrajeria y el stock). El id interno se deriva de hash FNV-1a sobre el trío (name, cuit, phone) — la dedupe es automática e idempotente: dos clientes con el mismo triple colapsan al mismo clientId.

01 Activar el módulo desde Configuración

Settings → Agenda con el master toggle apagado
01 Tab Agenda en Settings. Toggle Activar agenda de clientes. Apagado por default — al activarlo aparece el botón [K] en el topbar.

La agenda es un módulo gated — viene apagado por default (settings.clientsCatalogEnabled: false). Para activarlo:

  1. Abrí Configuración (botón [⚙] del topbar, esquina superior derecha).
  2. Tab Agenda (después de Presupuesto, antes de Corte).
  3. Toggle Activar agenda de clientes → ON.
  4. Cerrá Configuración con Esc o click en el scrim.
Sin activar. Si el toggle está apagado: el botón [K] del topbar desaparece y Shift+K muestra el toast "Agenda de clientes desactivada · activala en Settings → Agenda" (gate en src/ui/clients-catalog/guard.ts). El sheet [C] sigue funcionando en modo legacy (sin autocomplete, sin "Guardar al catálogo", sin badge de link).

02 Abrir el sheet [K] y ver el empty state

Sheet [K] con el empty state y el botón + Nuevo cliente
02 Sheet vacío con header + search. Título "Agenda de clientes", botón + Nuevo cliente, input de búsqueda accent-fold. Empty state con mensaje de ayuda.

Con el módulo ya activado, abrí la agenda con:

Se abre un panel sheet full-screen (centrado, mismo estilo visual que [C] y [Q]). En el header:

Empty state. Sin clientes cargados, la tabla muestra el mensaje "No hay clientes en la agenda todavía". La búsqueda no devuelve nada hasta que se cree al menos un cliente.

03 Crear un cliente desde la agenda

Sheet [C] abierto tras click en + Nuevo cliente
03 Sheet [C] abierto por "+ Nuevo cliente". Mismo form unificado de Cliente y orden, con los 7 campos vacíos y el slot del catálogo abajo del autocomplete (no se muestra Guardar al catálogo si no hay ≥ 1 de name/cuit/phone).

Click en + Nuevo cliente abre el sheet [C] Cliente y orden (mismo form que se usa para edición, no un drawer propio de [K]) con los 7 campos vacíos. La idea: el alta al catálogo es opt-in (D5 del spec) y se hace tipeando los datos en el [C] y haciendo click en el botón "💾 Guardar al catálogo" del footer.

Los 7 campos son:

CampoTipoDefaultSignificado
namestring""Nombre / Razón social
phonestring""Teléfono principal
emailstring""Email de contacto
addressstring""Dirección de entrega / fiscal
cuitstring""CUIT / Tax ID
orderNumberstring""N° de pedido default
projectNotesstring""Notas per-project
Validación mínima. El botón "Guardar al catálogo" solo aparece si hay ≥ 1 de name / cuit / phone no vacío (isSaveButtonVisible() en src/ui/client-panel/catalog-save-button.ts). Sirve para no permitir clientes anónimos vacíos.

Tras hacer click en Guardar al catálogo, el cliente aparece en la tabla de [K] con su id interno generado por hash FNV-1a sobre (name, cuit, phone). La fila muestra:

Idempotencia del ID. El mismo trío (name, cuit, phone) colapsa al mismo id. No existen duplicados por ese trío en la agenda (decisión D4 del spec). Si dos proyectos linkean al mismo clientId, comparten la misma ficha.

04 Buscar en vivo + highlight

Búsqueda sub-string accent-fold con highlight en amarillo
04 Search en vivo. Filtra por name / cuit / phone / email / address con highlight en amarillo de la sub-string matcheada.

El input arriba del listado filtra en vivo por cualquier sub-string en los 5 campos visibles (name, cuit, phone, email, address). El matching aplica normalizeForSearch (NFD + strip diacritics + lowercase): "Pérez" matchea "PEREZ", "García" matchea "garcia". Sub-string, no fuzzy — sin Levenshtein.

El texto coincidente se resalta en amarillo dentro de la celda (<mark class="clients-hl">), igual que en el picker de materiales.

Sin resultados. La tabla queda con una sola fila que dice "No hay clientes en la agenda todavía" (texto del empty state reusado). Para volver a la lista completa, limpiá el input con × o Esc dentro del campo.

05 Editar un cliente

Sheet [C] con datos pre-rellenados del cliente a editar
05 Sheet [C] con datos pre-rellenados. Al click en Editar (✎) desde [K], el sheet [C] se abre con los 7 campos del record + badge "🟢 Vinculado a la agenda" + botón "Actualizar en catálogo" en el footer.

Click en Editar (✎) de una fila abre el sheet [C] con los 7 campos pre-rellenados con los datos actuales del record, y el badge "🟢 Vinculado a la agenda" arriba (porque el record existe en el catálogo y el proyecto activo se linkea automáticamente). El botón del footer cambia de "Guardar" a "Actualizar en catálogo".

Cascada a proyectos linkeados. Los cambios que hagas en el [C] se aplican al hacer click en "Actualizar en catálogo", y sobreescriben los 7 campos del ClientRecord con los valores del state.project.client actual. No se propagan automáticamente a los demás proyectos del mismo cliente — cada uno mantiene su propio snapshot via state.project.clientSnapshot (decisión D12 del spec).

06 Ver proyectos vinculados a un cliente

Drawer Proyectos vinculados con la lista de .despiece del cliente
06 Drawer "Proyectos vinculados". Lista de .despiece que tienen ese cliente linkeado vía clientId. Cada item: nombre (link), formato y fecha de modificación.

Click en Ver proyectos (▤) de una fila abre un drawer modal centrado con la lista de archivos .despiece que tienen ese cliente linkeado vía clientId. Cada item muestra:

Cache de proyectos. El resultado de listProjectsByClient(id) se cachea en memoria en Map<clientId, ProjectSummary[]> durante la sesión. Se invalida al crear / borrar un .despiece. Al volver a abrir el drawer se ve la lista cacheada (sin volver a leer IDB).
Sin proyectos. El drawer muestra el mensaje "Sin proyectos vinculados todavía". Eso no bloquea el delete — un cliente sin proyectos se puede borrar sin confirmación extra.

07 Eliminar un cliente (con bloqueo si tiene proyectos)

ConfirmDialog para eliminar cliente
07 ConfirmDialog nativa. Título "Eliminar cliente", mensaje con el nombre, botones Cancelar / Eliminar (destructive primary). Esc cierra.

Click en Eliminar (×) de una fila abre un confirmDialog (modal nativa de la app, no window.confirm legacy) con:

Bloqueo si tiene proyectos vinculados. Si el cliente tiene al menos un .despiece con clientId apuntando a él, no se muestra el confirm — se dispara el toast con la lista de proyectos vinculados y se sugiere abrir "Ver proyectos" desde otra fila. El borrado del cliente siempre es manual (no hay "forzar borrado") — decisión D13 del spec.

Tras confirmar, el cliente se borra de IDB y la fila desaparece de la tabla. Si el proyecto activo tenía ese clientId, removeClient lo limpia localmente (ver paso 14).

08 Bulk select + eliminar + export CSV

Bulk bar con dos clientes seleccionados y conteo
08 Bulk actions. Selector + contador + botones Eliminar seleccionados y Exportar CSV. × limpia la selección. Select-all aplica a los visibles del filtro.

Marcar varias filas con los checkboxes de la columna izquierda habilita la bulk bar arriba del listado. La barra muestra:

Select-all. El checkbox del header selecciona todos los visibles del filtro actual (no del catálogo completo). Si filtrás por "Pérez" y tildás el header, exportás solo los Pérez.

Exportar CSV

El botón Exportar CSV genera un archivo UTF-8 BOM + CRLF con 5 columnas:

Nombre,CUIT,Teléfono,Email,Dirección
"Familia Pérez","30-12345678-9","+54 11 5555-1234","jperez@example.com","Av. Corrientes 1234, CABA"
"Estudio García","30-87654321-0","+54 11 4444-5678","hola@garciaestudio.com.ar","Belgrano 456, CABA"

Filename: agenda-clientes-{YYYY-MM-DD}.csv (fecha del día). Headers en español (no localizados — el export es para consumo fuera de la app: Excel, etc). Quoting RFC 4180: cualquier " se escapa con "". Toast de confirmación: "Exportadas N fila(s) a CSV".

Visible-only por default. Solo se exportan los clientes visibles del filtro actual que estén seleccionados (no los hidden del catálogo). No hay toggle para incluir los filtrados — el patrón es coherente con el export de BOM.

Bulk delete

Click en Eliminar seleccionados abre un confirmDialog con el conteo ("¿Eliminar N cliente(s) del catálogo?"). Cada uno se borra con removeClient(id) (limpia FK del proyecto activo si estaba linkeado). Toast final: "N cliente(s) eliminado(s) de la agenda".

09 Abrir el sheet [C] Cliente y orden

Sheet [C] Cliente y orden con campos vacíos y sección Agenda activa
09 Sheet [C] con la sección Agenda visible. Input de autocomplete arriba del campo Nombre + link indicator + botón "Guardar al catálogo" en el footer (si la agenda está activada).

Ahora la segunda parte del flujo: cómo la agenda se integra con el sheet [C] mientras armás un proyecto. Abrilo con:

Es un sheet modal centrado con los 7 campos del cliente del proyecto activo (state.project.client). El footer tiene un botón Vaciar datos (destructive) y — si la agenda está activada — un slot adicional para el botón del catálogo (ver paso 12).

Sin agenda activada. Si el toggle de Settings está apagado, el sheet [C] muestra solo los 7 campos. Sin autocomplete, sin "Guardar al catálogo", sin badge de link. Funciona como siempre, los datos viven en state.project.client y se pierden si no se guarda el .despiece.

10 Autocomplete en [C] desde la agenda

Autocomplete en [C] filtrando Pér y mostrando dos resultados
10 Autocomplete en vivo. Debounce 80 ms, máx 5 resultados. Cada item: nombre (bold) + CUIT + teléfono. Click → trae los 5 campos de identificación al sheet.

Si la agenda tiene clientes y la integración está activada, arriba del campo Nombre aparece una sección de autocomplete:

Empty input. Con el campo vacío, el dropdown muestra solo el hint "Empezá a tipear para buscar en la agenda"no lista todos los clientes (sería overwhelming en agendas grandes).
Sin agenda activa. El input y la sección de autocomplete no se renderizan (gate clientsCatalogEnabled). El sheet [C] queda igual que antes.

Caveat UX: full overwrite

Traer datos de la agenda reemplaza los 5 campos de identificación (name, phone, email, address, cuit) en state.project.client por los del record seleccionado. Los campos per-project (orderNumber, projectNotes) se conservan — no son parte del ClientRecord.

Cuándo NO usar el autocomplete. Si ya tipeaste orderNumber o projectNotes per-project y querés mantenerlos, no piqués un cliente encima — el autocomplete los preserva, pero perdés cualquier name/phone que hubieras tipeado a mano en esta sesión y no hayas sincronizado todavía.

11 Guardar un cliente nuevo al catálogo desde [C]

Botón Guardar al catálogo en el footer del sheet [C]
11 Botón "Guardar al catálogo". Slot adicional en el footer (no primary). Aparece solo si hay ≥ 1 de name/cuit/phone no vacío. Click → flushea RUL → create + link.

Si el state.project.client tiene al menos uno de name / cuit / phone no vacío, aparece un botón 💾 Guardar al catálogo en el footer del sheet [C] (al lado del texto del footer, no como primary).

Por qué no es primary. El catálogo es opt-in (decisión D5 del spec). El flujo principal sigue siendo tipear + guardar .despiece; el alta al catálogo es una acción secundaria para "guardar este cliente para reusar".

Click en Guardar al catálogo:

  1. Flushea los debounce timers del RUL (120 ms) para evitar el bug del "doble click" — ver AGENTS.md §6.2.
  2. Llama ensureCatalogEntryFromSnapshot(client) → crea o reutiliza el ClientRecord por hash FNV-1a.
  3. Llama linkProjectClient(...) → setea state.project.clientId + state.project.clientSnapshot + bumpea clientEpoch.
  4. Toast: "Cliente vinculado a la agenda" + badge inline ✓ Guardado durante 2 s.

12 Badge "Vinculado a la agenda" + "Actualizar en catálogo"

Sheet [C] con el badge Vinculado a la agenda y el botón Actualizar
12 Link indicator + botón Actualizar. Badge verde 🟢 Vinculado + links Ver en agenda / Desvincular. El footer pasa de "Guardar" a "Actualizar en catálogo".

Una vez linkeado, el sheet [C] muestra una línea de estado debajo del autocomplete con:

Doble flujo. El catálogo (ClientRecord) y el proyecto (state.project.clientSnapshot) son independientes después del link. Editar el [K] no toca el proyecto; editar el [C] + "Actualizar" no toca a los demás proyectos del mismo cliente.

13 Re-guardar (Actualizar en catálogo)

Sheet [C] con toast Cliente actualizado en la agenda
13 Update mode. Segundo click en el botón del footer (ahora "Actualizar") → toast "Cliente actualizado en la agenda" en vez de "guardado". Bumpea epoch y re-renderiza la lista en [K].

Si después de guardar volvés a hacer click en el botón del footer (que ahora dice "Actualizar en catálogo"), se ejecuta el modo update:

Dedupe FNV-1a. El clientId se deriva del hash FNV-1a sobre (name, cuit, phone). Si los 3 campos matchean un record existente, la ensureCatalogEntryFromSnapshot retorna { clientId: existing, created: false } y se cae al branch update (no se duplica). Si cambian uno de los tres, el hash cambia → se crea un nuevo record (independiente del anterior).

14 Desvincular (romper el link sin borrar el record)

Sheet [C] después de click en Desvincular
14 Link roto. Badge 🟢 Vinculado desaparece; el footer vuelve a 💾 Guardar (no Actualizar). El record en [K] queda intacto para re-vincular más adelante.

Si querés romper el link entre el proyecto y el cliente del catálogo sin borrar el record (por ejemplo: este proyecto es de un cliente distinto con datos similares), click en el link "Desvincular" del badge verde.

Qué hace. unlinkProjectClient() setea state.project.clientId = undefined (bumpea el epoch) pero preserva state.project.clientSnapshot — los PDFs siguen mostrando los datos del cliente que estaban al momento del link. El record en [K] no se toca (queda ahí para re-vincular más adelante).

Tras desvincular:

Cliente huérfano. El estado huérfano (clientId seteado pero record borrado del catálogo) no se puede alcanzar desde la UI de un solo device — removeClient en [K] limpia clientId local antes de borrar el record (decisión D12). Solo aparece en escenarios de sync multi-device: si borrás un cliente en otro browser/device mientras este proyecto está abierto acá, clientId queda apuntando a un record inexistente y la UI muestra el badge "⚠ Cliente huérfano de la agenda" + link "Volver a vincular". El snapshot se preserva igual, así que los PDFs no se rompen.

15 Atajos + Settings + tips finales

Atajos

AtajoAcción
Shift+KAbre el sheet [K] Agenda (solo abre: con el sheet abierto se cierra con Esc, con la × o con el scrim)
Shift+CAbre el sheet [C] Cliente y orden (solo abre: con el sheet abierto se cierra con Esc, con la × o con el scrim)
Esc (con sheet abierto)Cierra el sheet / cierra drawer con cambios sin guardar
Click en [K] topbarAbre el sheet (solo abre: con el sheet abierto el scrim del modal tapa el topbar y el click no llega al botón)
Click en [C] topbarAbre el sheet (solo abre: con el sheet abierto el scrim del modal tapa el topbar y el click no llega al botón)
Gate. Si el toggle de Settings está apagado, Shift+K muestra el toast "Agenda de clientes desactivada · activala en Settings ▸ Agenda" y no abre nada (gate en src/ui/clients-catalog/guard.ts:clientsCatalogGuard).

Posición del tab en Settings

El tab Agenda está después de Presupuesto y antes de Corte.

Si no lo ves, scrolleá los tabs horizontalmente — está casi al medio.

Tips de uso

Recalculo automático. El sheet [K] re-renderiza cuando cambia clientsCatalogEpoch (bumpea en cada add / update / remove). El sheet [C] se sincroniza via reactOnState y re-renderiza solo el bloque del catálogo (preserva foco y scroll en los inputs del usuario — patrón RUL, ver AGENTS.md §6.2). F5 recarga la página: la agenda se hidrata desde IDB despiece-clients v2 en bootstrapClientsCatalog() post-attachStateListener.

Ver también