<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Constrúyeme esta herramienta: Sanierung

*Todo lo que sigue es la especificación funcional. Síguela tal como está; donde calle, decide tú y di qué supusiste.*

## Punto de partida

Usa la plantilla **openToolbox**: https://github.com/m-dohmen/openToolbox. Lee primero `AGENTS.md` en ese repositorio — es la autoridad sobre la forma del esquema, los tipos de campo y las reglas que rompen una compilación de archivo único. Todo lo específico del dominio va en un solo archivo, `src/domain.js`.

> Si tienes instalado el skill de openToolbox (`claude plugin install opentoolbox@opentoolbox`), basta con pegar este archivo — él mismo se trae la plantilla.

## El problema que resuelve

Alguien reforma una casa. Para cada gremio pide tres presupuestos: uno no llega nunca, otro cuesta el doble de lo previsto, y tres meses después nadie recuerda por qué se eligió el del medio. Al final llega la pregunta que todo promotor hace tarde: **¿seguimos dentro del presupuesto?** La misma forma que una cartera de proyectos, pero privada, y con plazos de validez que vencen en silencio.

## Qué debe quedar al final

Un único archivo HTML autocontenido, que se abre con doble clic, sin servidor y sin instalación. El archivo es también la base de datos: guardar escribe un nuevo HTML con los registros incrustados.

## Tipos de registro

Clave de entidad `trades` — un registro es **Gewerk**, varios son **Gewerke**.

**Campos**

| Clave | Etiqueta | Tipo | Detalle |
| --- | --- | --- | --- |
| `name` | Gewerk | text | obligatorio |
| `trade` | Kategorie | enum | uno de: Rohbau · Dach · Fenster · Elektro · Sanitär/Heizung · Estrich · Trockenbau · Maler · Boden · Außenanlage |
| `phase` | Stand | enum | uno de: noch nicht angefragt · Angebote eingeholt · beauftragt · in Ausführung · abgenommen |
| `budget` | Budget (€) | number | cabecera de tabla `Budget` |
| `final` | Schlussrechnung (€) | number | cabecera de tabla `Schluss` |
| `start` | Geplanter Beginn | date | cabecera de tabla `Beginn` |
| `end` | Geplantes Ende | date | cabecera de tabla `Ende` |
| `note` | Notiz | text | varias líneas |
| `awarded` | Beauftragt (€) | computed | calculado, nunca almacenado, cabecera de tabla `Auftrag` |
| `variance` | Abweichung zum Budget (€) | computed | calculado, nunca almacenado, cabecera de tabla `± €` |
| `offerCount` | Angebote | computed | calculado, nunca almacenado, cabecera de tabla `Ang.` |

**Presentación**

- Columna principal: `name`
- Segunda línea debajo: `trade`
- Columnas de la tabla, en este orden: `name`, `trade`, `phase`, `budget`, `awarded`, `variance`, `start`
- Filtros de la barra lateral: `trade`, `phase`
- Sumado en el resumen: `budget`
- Deja de contar como abierto cuando: `r.phase === 'abgenommen'`
- Se marca en rojo cuando: `r.phase === 'noch nicht angefragt' && r.start && r.start < iso(30)`

**Campos calculados**

Se derivan en cada renderizado y nunca se escriben en el registro: una derivación almacenada queda obsoleta en cuanto cambia una de sus entradas.

- `awarded` (Beauftragt (€)) — `offersOf(r.id).filter((o) => o.state === 'beauftragt').reduce((s, o) => s + (Number(o.amount) || 0), 0)`
- `variance` (Abweichung zum Budget (€)):

  ```js
  const awarded = offersOf(r.id)
    .filter((o) => o.state === 'beauftragt')
    .reduce((s, o) => s + (Number(o.amount) || 0), 0)
  const actual = Number(r.final) || awarded
  if (!actual) return ''
  return (Number(r.budget) || 0) - actual
  ```
- `offerCount` (Angebote) — `offersOf(r.id).length`

**Reglas de validación**

Condiciones entre campos. Deben aplicarse en un único lugar para que el formulario, la importación CSV y lo que proponga la IA pasen por la misma comprobación.

- **Cuando** `r.phase === 'beauftragt' || r.phase === 'in Ausführung' || r.phase === 'abgenommen'` → **Entonces** `offersOf(r.id).some((o) => o.state === 'beauftragt')`
  **Mensaje:** „Beauftragt heißt: eines der Angebote steht auf „beauftragt".“
- **Cuando** `r.phase === 'abgenommen'` → **Entonces** `final`
  **Mensaje:** „Nach der Abnahme gehört die Schlussrechnung dazu — sonst bleibt die Abweichung geraten.“
- **Cuando** `Boolean(r.start && r.end)` → **Entonces** `r.end >= r.start`
  **Mensaje:** „Das Ende kann nicht vor dem Beginn liegen.“

---

Clave de entidad `offers` — un registro es **Angebot**, varios son **Angebote**.

**Campos**

| Clave | Etiqueta | Tipo | Detalle |
| --- | --- | --- | --- |
| `company` | Firma | text | obligatorio |
| `tradeId` | Gewerk | reference | obligatorio, referencia a `trades` |
| `contact` | Ansprechpartner | text | cabecera de tabla `Kontakt` |
| `amount` | Summe brutto (€) | number | cabecera de tabla `€` |
| `received` | Eingegangen am | date | cabecera de tabla `Eingang` |
| `validUntil` | Bindefrist bis | date | cabecera de tabla `Bindefrist` |
| `state` | Status | enum | uno de: angefragt · liegt vor · nachverhandelt · abgelehnt · beauftragt |
| `pdf` | Angebot als Datei | attachment | archivo adjunto, guardado en el registro, cabecera de tabla `PDF` |
| `note` | Notiz | text | varias líneas |
| `perBudget` | Anteil am Budget | computed | calculado, nunca almacenado, cabecera de tabla `% Budget` |

**Presentación**

- Columna principal: `company`
- Columnas de la tabla, en este orden: `company`, `tradeId`, `amount`, `perBudget`, `received`, `validUntil`, `state`
- Filtros de la barra lateral: `state`
- Sumado en el resumen: `amount`
- Deja de contar como abierto cuando: `r.state === 'beauftragt' || r.state === 'abgelehnt'`
- Se marca en rojo cuando: `r.state !== 'abgelehnt' && r.state !== 'beauftragt' && r.validUntil && r.validUntil < iso(0)`

**Campos calculados**

Se derivan en cada renderizado y nunca se escriben en el registro: una derivación almacenada queda obsoleta en cuanto cambia una de sus entradas.

- `perBudget` (Anteil am Budget):

  ```js
  const trade = ENTITIES.trades.seed().find((t) => t.id === r.tradeId)
  if (!trade?.budget) return ''
  return Math.round(((Number(r.amount) || 0) / trade.budget) * 100) + ' %'
  ```

**Reglas de validación**

Condiciones entre campos. Deben aplicarse en un único lugar para que el formulario, la importación CSV y lo que proponga la IA pasen por la misma comprobación.

- **Cuando** `r.state !== 'angefragt'` → **Entonces** `amount`, `received`
  **Mensaje:** „Ein vorliegendes Angebot hat eine Summe und ein Eingangsdatum.“
- **Cuando** `r.state === 'abgelehnt'` → **Entonces** `note`
  **Mensaje:** „Warum abgelehnt? In drei Monaten weiß das sonst niemand mehr.“

---

## Panel

Fichas sobre todo el conjunto de registros, no sobre la vista filtrada.

- Un número: **Budget gesamt** — `budget` (Budget (€)) · „€ über alle Gewerke“
- Un número: **Beauftragt** — `amount` (Summe brutto (€)) (solo registros que cumplen un filtro): `r.state === 'beauftragt'` · „€ bereits vergeben“
- Un número: **Nicht angefragt** — el número de registros (solo registros que cumplen un filtro): `r.phase === 'noch nicht angefragt'` · „Gewerke ohne Angebot“
- Un anillo por valor de `phase`
- Barras por valor de `trade`, midiendo `budget` (Budget (€)) — **Budget je Kategorie (€)**
- Barras por valor de `state`, midiendo el número de registros — **Angebote nach Status**

## Captura guiada

Una secuencia breve de pasos para quien tiene que reportar una cosa y no conoce la herramienta. No se escribe nada hasta confirmar el último paso: abandonarla no debe dejar rastro.

Título: **Angebot erfassen**

> Erst das Gewerk, dann das Angebot dazu. Beides entsteht in einem Durchgang.

1. **Paso 1 `trades`** — Gewerk: campos: `name`, `trade`, `budget`, `start`, `end`
2. **Paso 2 `offers`** — Angebot: campos: `company`, `tradeId`, `contact`, `amount`, `received`, `validUntil`, `state`, `note`
   solo cuando: `Boolean(drafts.trades?.name)`
3. **Paso 3** — Prüfen: resumen generado a partir del esquema

Pantalla final: «Erfasst. Die Abweichung zum Budget rechnet sich mit.»

## Valores por defecto

Ponlos en `DEFAULT_SETTINGS`, `DEFAULT_COLORS` y `DEFAULT_HOME` en `src/app.jsx`.

- Título: **Sanierung**
- Subtítulo: Gewerke, Angebote und wo das Budget steht
- Nombre de archivo: `sanierung`
- Versión: `1.0`
- Idioma de la interfaz: `de`
- Se abre como: herramienta completa
- Colores: `accent` #6b4a8c · `band` #241b2e · `flag` #b4442e · `ok` #3f7a5c · `pending` #c08a12

## Página de inicio

La aplicación abre con este texto. Es un subconjunto de Markdown: títulos, listas, citas, negrita, cursiva, código en línea y enlaces. Úsalo literalmente:

```markdown
# Sanierung: Gewerke und Angebote

Für jedes Gewerk holt man drei Angebote ein. Eines kommt nie, eines ist doppelt so teuer wie
gedacht, und drei Monate später weiß niemand mehr, warum die Wahl auf den mittleren fiel. Am Ende
steht die Frage, die jeder Bauherr zu spät stellt: **sind wir noch im Budget?**

## Was diese Demo zeigt

- **Mehrfachauswahl und Sammelaktionen** — das Kontrollkästchen am Zeilenanfang wählt aus,
  Umschalt+Klick nimmt einen ganzen Bereich dazu, der Kopf wählt alles Sichtbare. In der
  Aktionsleiste darunter setzt der Status aller Gewählten auf einen Wert oder sie werden gemeinsam
  gelöscht — ein Durchlauf, ein Protokollschritt, ein Strg+Z für alles. Zusammen mit dem Filter
  „liegt vor" bekommen die unterlegenen Anbieter eines Gewerks ihre Absage in einem Rutsch; die
  Regel bleibt auch im Schwarm wirksam: die Absage ohne Begründung wird übersprungen und benannt.
- **Duplizieren** — Angebote je Gewerk wiederholen sich: das nächste entsteht als Kopie eines
  vorhandenen (Aktion im Zeilenmenü oder im offenen Datensatz), Gewerk-Verweis und Bindefrist
  bleiben stehen, geändert werden nur Firma, Kontakt und Summe. Die Kopie erscheint im
  Änderungsprotokoll und lässt sich mit Strg+Z wieder entfernen.
- **Zwei Datenarten**: ein Angebot ohne sein Gewerk hat keine Aussage, ein Gewerk bekommt seine
  Zahl erst durch die Angebote.
- **Die Auftragssumme wird nicht getippt**, sie steht im beauftragten Angebot und wird von dort
  geholt. Abweichung zum Budget ebenso.
- **Ablaufende Bindefristen werden rot** — der teuerste übersehene Termin am Bau.
- **Regeln gegen das Vergessen**: ein abgelehntes Angebot verlangt eine Begründung.

> Erfundene Zahlen und Firmen.
```

## Datos de ejemplo

Añade Gewerke: 10, Angebote: 17 registros de ejemplo realistas para que el archivo no esté vacío al abrirlo. Invéntalos al estilo de los campos anteriores; son ilustración, no los datos del usuario. Dile que sus datos entran por **Import CSV → replace all**.

## Terminado cuando

- `npm run build` produce un único `dist/index.html` autocontenido
- `npm test` pasa
- el archivo se abre con doble clic y muestra los registros de ejemplo
- los campos calculados muestran valores y las reglas rechazan un registro que las incumpla
- los ajustes, colores y página de inicio coinciden con lo especificado arriba

## Antes de entregarla

Decide esto tú, no lo dejes al destinatario: pon `copyright` a quien sea dueño de la herramienta, sustituye el enlace de cabecera que apunta al repositorio de openToolbox y desactiva `examplePrompts` si el destinatario solo introduce datos. Menciona el contador de aperturas (Ajustes → Seguridad) al entregarla.

---

*Generado a partir de `examples/renovation-quotes.domain.js`, el código real de la [demo en vivo](https://m-dohmen.github.io/openToolbox/demos/renovation-quotes/). Regenerar con `npm run prompts`.*

*Todos los datos de la demo son inventados. Ilustra la estructura de una herramienta así — no es asesoramiento legal ni prueba de conformidad.*
