# Tutorial · 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 (listar, buscar, crear, editar, eliminar, bulk + CSV), y el
sheet `[C]` para **usarla** mientras armás un proyecto (autocomplete,
guardar al catálogo, link indicator). Los dos se abren con atajos
independientes (`Shift+K` y `Shift+C`) y comparten un único store
persistente.

> Antes llamado "Catálogo de clientes" — renombrado a **Agenda** para
> diferenciarse del Catálogo de herraje (mismo patrón UI pero distinto
> dominio). La carpeta y el namespace i18n siguen siendo `clients.*`
> (interno); solo cambia la etiqueta visible.

---

## 1. Activar el módulo desde Configuración

![Settings → Agenda con el master toggle apagado](img/01-settings-off.jpg)

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).

## 2. Abrir el sheet `[K]` y ver el empty state

![Sheet [K] con el empty state y el botón "+ Nuevo cliente"](img/02-empty-state.jpg)

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…"*).

> **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.

## 3. Crear un cliente desde la agenda

![Sheet [C] abierto tras click en + Nuevo cliente](img/03-new-drawer.jpg)

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 |

> **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:

- Checkbox para bulk select (columna izquierda).
- **Nombre** (negrita) · **CUIT** · **Teléfono** · **Email** · **Dirección**.
- Botones por fila: **Ver proyectos** (▤) · **Editar** (✎) · **Eliminar** (×).

> **Idempotencia del ID.** El mismo triple `(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.

## 4. Buscar en vivo + highlight

![Búsqueda sub-string accent-fold con highlight en amarillo](img/04-search.jpg)

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.

## 5. Editar un cliente

![Sheet [C] con datos pre-rellenados del cliente a editar](img/05-edit-drawer.jpg)

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).

## 6. Ver proyectos vinculados a un cliente

![Drawer "Proyectos vinculados" con la lista de .despiece del cliente](img/06-projects-drawer.jpg)

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.

> **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.

## 7. Eliminar un cliente (con bloqueo si tiene proyectos)

![ConfirmDialog para eliminar cliente](img/07-delete-confirm.jpg)

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).

> **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`, pasa a estado
**huérfano** (ver paso 14).

## 8. Bulk select + eliminar + export CSV

![Bulk bar con dos clientes seleccionados y conteo](img/08-bulk-bar.jpg)

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.

> **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"*.

---

## 9. Abrir el sheet `[C]` Cliente y orden

![Sheet [C] Cliente y orden con campos vacíos y sección Agenda activa](img/09-client-panel.jpg)

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).

> **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](img/10-autocomplete.jpg)

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ía `pickClientFromCatalog(id)`.

> **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]](img/11-save-to-catalog.jpg)

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](img/12-linked-badge.jpg)

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 del `ClientRecord` con los
  valores actuales del `state.project.client`).

> **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"](img/13-dedupe-toast.jpg)

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 del
  `ClientRecord` con los valores actuales del `state.project.client`.
- Toast: *"Cliente actualizado en la agenda"* (no *"guardado"*).
- Bumpea `clientsCatalogEpoch` → la lista en `[K]` se re-renderiza
  con los nuevos datos.

> **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 (huérfano del anterior — ver paso 14 para huérfanos).

## 14. Desvincular (romper el link sin borrar el record)

![Sheet [C] después de click en Desvincular](img/14-unlink.jpg)

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:

- 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 → `pickClientFromCatalog` lo re-linkea.

> **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

| 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) |

> **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

- **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
  `.despiece` sigue ahí con su `clientSnapshot`.

> **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

- [Tutorial · Stock del taller](stock.html) — 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](presupuesto-y-export-pdf.html)
  — el PDF del Presupuesto lee `state.project.client` (no el catálogo)
  via `clientSnapshot`, así que si el cliente se borró del catálogo el
  PDF sigue funcionando.
- [Tutorial · Materiales del taller](materiales.html) — flujo paralelo
  con catálogo + bulk assign + picker en lista plana.
- Spec [`2026-07-26-customer-catalog-v2.md`](../superpowers/specs/2026-07-26-customer-catalog-v2.md)
  — diseño del módulo CRM v2, decisiones D1–D20, schema,
  integraciones `[C]↔[K]`.
