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.
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
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:
- Abrí Configuración (botón
[⚙]del topbar, esquina superior derecha). - Tab Agenda (después de Presupuesto, antes de Corte).
- Toggle Activar agenda de clientes → ON.
- Cerrá Configuración con Esc o click en el scrim.
[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
+ Nuevo cliente, input de búsqueda accent-fold. Empty state con mensaje de ayuda.
Con el módulo ya activado, abrí la agenda con:
- Atajo Shift+K (también ⇧+K en Mac).
- Botón
[K]del topbar (icono de grupo de personas, entre[C]Cliente y orden y[T]Stock).
Se abre un panel sheet full-screen (centrado, mismo estilo visual que [C] y [Q]). En el header:
- Título Agenda de clientes.
- Botón
+ Nuevo cliente(esquina superior derecha). - Botón
×para cerrar. - Input de búsqueda accent-fold abajo del header (placeholder: "Buscar por nombre, CUIT o teléfono…").
03 Crear un cliente desde la agenda
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:
| Campo | Tipo | Default | Significado |
|---|---|---|---|
name | string | "" | Nombre / Razón social |
phone | string | "" | Teléfono principal |
email | string | "" | Email de contacto |
address | string | "" | Dirección de entrega / fiscal |
cuit | string | "" | CUIT / Tax ID |
orderNumber | string | "" | N° de pedido default |
projectNotes | string | "" | Notas per-project |
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:
- Checkbox para bulk select (columna izquierda).
- Nombre (negrita) · CUIT · Teléfono · Email · Dirección.
- Botones por fila: Ver proyectos (▤) · Editar (✎) · Eliminar (×).
(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
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.
× o Esc dentro del campo.
05 Editar un cliente
[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".
[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
.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:
- Nombre del proyecto (link que lo abre).
- Formato + última modificación (formato corto YYYY-MM-DD).
- Indicador visual si tiene presupuesto y/o hoja de armado asociados.
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).
07 Eliminar un cliente (con bloqueo si tiene proyectos)
Click en Eliminar (×) de una fila abre un confirmDialog (modal nativa de la app, no window.confirm legacy) con:
- Título "Eliminar cliente".
- Mensaje "¿Eliminar a {name} de la agenda? Esta acción no se puede deshacer."
- Botones Cancelar / Eliminar (destructive primary).
.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
× 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:
- "N seleccionados" (contador dinámico).
- Botón Eliminar seleccionados (rojo destructive).
- Botón Exportar CSV (exporta solo los seleccionados visibles).
×para limpiar la selección.
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".
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
Ahora la segunda parte del flujo: cómo la agenda se integra con el sheet [C] mientras armás un proyecto. Abrilo con:
- Atajo Shift+C (también ⇧+C en Mac).
- Botón
[C]del topbar (icono de persona).
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).
[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
Si la agenda tiene clientes y la integración está activada, arriba del campo Nombre aparece una sección de autocomplete:
- Input con placeholder "Buscar en la agenda…".
- Dropdown que aparece al tipear (debounce 80 ms, máx 5 resultados).
- Cada resultado muestra
nombre(bold) + CUIT + teléfono en gris. - Click en un resultado → trae los 5 campos de identificación del cliente al
[C]víapickClientFromCatalog(id).
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.
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]
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).
.despiece; el alta al catálogo es una acción secundaria para "guardar este cliente para reusar".
Click en Guardar al catálogo:
- Flushea los debounce timers del RUL (120 ms) para evitar el bug del "doble click" — ver AGENTS.md §6.2.
- Llama
ensureCatalogEntryFromSnapshot(client)→ crea o reutiliza elClientRecordpor hash FNV-1a. - Llama
linkProjectClient(...)→ seteastate.project.clientId+state.project.clientSnapshot+ bumpeaclientEpoch. - Toast: "Cliente vinculado a la agenda" + badge inline
✓ Guardadodurante 2 s.
12 Badge "Vinculado a la agenda" + "Actualizar en catálogo"
🟢 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:
- 🟢 Vinculado a la agenda (badge verde) + link "Ver en agenda" (abre
[K]elevated, z-index arriba de[C]). - Link "Desvincular" (rompe el link sin borrar el record).
- Botón "Actualizar en catálogo" en el footer (reemplaza al "Guardar" original) — sincroniza los cambios del
[C]al record del catálogo (sobreescribe los 7 campos delClientRecordcon los valores actuales delstate.project.client).
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)
[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:
updateClient(clientId, snap)sobreescribe los 7 campos delClientRecordcon los valores actuales delstate.project.client.- Toast: "Cliente actualizado en la agenda" (no "guardado").
- Bumpea
clientsCatalogEpoch→ la lista en[K]se re-renderiza con los nuevos datos.
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)
🟢 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.
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:
- El badge "🟢 Vinculado a la agenda" desaparece.
- El botón del footer vuelve a "💾 Guardar" (no "Actualizar").
- Si querés volver a linkear: autocomplete en
[C]→ elegí el mismo cliente →pickClientFromCataloglo re-linkea.
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
| Atajo | Acción |
|---|---|
| Shift+K | Abre el sheet [K] Agenda (solo abre: con el sheet abierto se cierra con Esc, con la × o con el scrim) |
| Shift+C | Abre 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] topbar | Abre 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] topbar | Abre el sheet (solo abre: con el sheet abierto el scrim del modal tapa el topbar y el click no llega al botón) |
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
- Bulk select con Shift+click para selección rápida.
- CSV para importar en Excel / Google Sheets y mandar mailings.
- Ver proyectos antes de borrar — siempre.
- Guardar al catálogo desde
[C]apenas confirmes que es un cliente recurrente — mejor tenerlo una vez de más que tipearlo 3 veces en 3 proyectos distintos. - No borres un cliente con proyectos linkeados desde
[K]pensando que va a borrar el.despiece— solo rompe el link. El.despiecesigue ahí con suclientSnapshot.
[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
- Tutorial · Stock del taller — feature paralelo: mismo patrón de sheet gated + IDB + atajo Shift+T. La agenda y el stock comparten el concepto de catálogo workshop-global que no se exporta dentro del
.despiece. - Tutorial · Presupuesto al cliente — el PDF del Presupuesto lee
state.project.client(no el catálogo) viaclientSnapshot, así que si el cliente se borró del catálogo el PDF sigue funcionando. - Tutorial · Materiales del taller — flujo paralelo con catálogo + bulk assign + picker en lista plana.
- Spec
2026-07-26-customer-catalog-v2.md— diseño del módulo CRM v2, decisiones D1–D20, schema, integraciones[C]↔[K].