<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Construa esta ferramenta para mim: Sanierung

*Tudo o que segue é a especificação funcional. Siga-a como está; onde ela se calar, decida e diga o que assumiu.*

## Ponto de partida

Use o modelo **openToolbox**: https://github.com/m-dohmen/openToolbox. Leia primeiro o `AGENTS.md` desse repositório — ele é a autoridade sobre a forma do esquema, os tipos de campo e as regras que quebram uma compilação de arquivo único. Tudo o que é do domínio vai em um único arquivo, `src/domain.js`.

> Se o skill do openToolbox estiver instalado (`claude plugin install opentoolbox@opentoolbox`), basta colar este arquivo — ele mesmo busca o modelo.

## O problema por trás disso

Alguém reforma uma casa. Para cada serviço pede três orçamentos: um nunca chega, outro custa o dobro do esperado, e três meses depois ninguém lembra por que ficaram com o do meio. No fim vem a pergunta que todo dono de obra faz tarde demais: **ainda estamos dentro do orçamento?** A mesma forma de uma carteira de projetos, só que privada — e com prazos de validade que vencem em silêncio.

## O que precisa existir no fim

Um único arquivo HTML autocontido, aberto com duplo clique, sem servidor e sem instalação. O arquivo também é o banco de dados: salvar escreve um novo HTML com os registros embutidos.

## Tipos de registro

Chave de entidade `trades` — um registro é **Gewerk**, vários são **Gewerke**.

**Campos**

| Chave | Rótulo | Tipo | Detalhe |
| --- | --- | --- | --- |
| `name` | Gewerk | text | obrigatório |
| `trade` | Kategorie | enum | um de: Rohbau · Dach · Fenster · Elektro · Sanitär/Heizung · Estrich · Trockenbau · Maler · Boden · Außenanlage |
| `phase` | Stand | enum | um de: noch nicht angefragt · Angebote eingeholt · beauftragt · in Ausführung · abgenommen |
| `budget` | Budget (€) | number | cabeçalho de tabela `Budget` |
| `final` | Schlussrechnung (€) | number | cabeçalho de tabela `Schluss` |
| `start` | Geplanter Beginn | date | cabeçalho de tabela `Beginn` |
| `end` | Geplantes Ende | date | cabeçalho de tabela `Ende` |
| `note` | Notiz | text | multilinha |
| `awarded` | Beauftragt (€) | computed | calculado, nunca armazenado, cabeçalho de tabela `Auftrag` |
| `variance` | Abweichung zum Budget (€) | computed | calculado, nunca armazenado, cabeçalho de tabela `± €` |
| `offerCount` | Angebote | computed | calculado, nunca armazenado, cabeçalho de tabela `Ang.` |

**Apresentação**

- Coluna principal: `name`
- Segunda linha abaixo: `trade`
- Colunas da tabela, nesta ordem: `name`, `trade`, `phase`, `budget`, `awarded`, `variance`, `start`
- Filtros da barra lateral: `trade`, `phase`
- Somado no resumo: `budget`
- Deixa de contar como aberto quando: `r.phase === 'abgenommen'`
- Marcado em vermelho quando: `r.phase === 'noch nicht angefragt' && r.start && r.start < iso(30)`

**Campos calculados**

São derivados a cada renderização e nunca gravados no registro — uma derivação armazenada fica desatualizada assim que uma de suas entradas muda.

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

**Regras de validação**

Condições entre campos. Precisam valer em um único lugar, para que o formulário, a importação CSV e o que a IA propuser passem pela mesma verificação.

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

---

Chave de entidade `offers` — um registro é **Angebot**, vários são **Angebote**.

**Campos**

| Chave | Rótulo | Tipo | Detalhe |
| --- | --- | --- | --- |
| `company` | Firma | text | obrigatório |
| `tradeId` | Gewerk | reference | obrigatório, referência para `trades` |
| `contact` | Ansprechpartner | text | cabeçalho de tabela `Kontakt` |
| `amount` | Summe brutto (€) | number | cabeçalho de tabela `€` |
| `received` | Eingegangen am | date | cabeçalho de tabela `Eingang` |
| `validUntil` | Bindefrist bis | date | cabeçalho de tabela `Bindefrist` |
| `state` | Status | enum | um de: angefragt · liegt vor · nachverhandelt · abgelehnt · beauftragt |
| `pdf` | Angebot als Datei | attachment | arquivo anexado, guardado no registro, cabeçalho de tabela `PDF` |
| `note` | Notiz | text | multilinha |
| `perBudget` | Anteil am Budget | computed | calculado, nunca armazenado, cabeçalho de tabela `% Budget` |

**Apresentação**

- Coluna principal: `company`
- Colunas da tabela, nesta ordem: `company`, `tradeId`, `amount`, `perBudget`, `received`, `validUntil`, `state`
- Filtros da barra lateral: `state`
- Somado no resumo: `amount`
- Deixa de contar como aberto quando: `r.state === 'beauftragt' || r.state === 'abgelehnt'`
- Marcado em vermelho quando: `r.state !== 'abgelehnt' && r.state !== 'beauftragt' && r.validUntil && r.validUntil < iso(0)`

**Campos calculados**

São derivados a cada renderização e nunca gravados no registro — uma derivação armazenada fica desatualizada assim que uma de suas entradas muda.

- `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) + ' %'
  ```

**Regras de validação**

Condições entre campos. Precisam valer em um único lugar, para que o formulário, a importação CSV e o que a IA propuser passem pela mesma verificação.

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

---

## Painel

Blocos sobre todo o conjunto de registros, não sobre a visão filtrada.

- Um número: **Budget gesamt** — `budget` (Budget (€)) · „€ über alle Gewerke“
- Um número: **Beauftragt** — `amount` (Summe brutto (€)) (apenas registros que atendem a um filtro): `r.state === 'beauftragt'` · „€ bereits vergeben“
- Um número: **Nicht angefragt** — a quantidade de registros (apenas registros que atendem a um filtro): `r.phase === 'noch nicht angefragt'` · „Gewerke ohne Angebot“
- Um anel por valor de `phase`
- Barras por valor de `trade`, medindo `budget` (Budget (€)) — **Budget je Kategorie (€)**
- Barras por valor de `state`, medindo a quantidade de registros — **Angebote nach Status**

## Captura guiada

Uma sequência curta de passos para quem precisa relatar uma coisa e não conhece a ferramenta. Nada é gravado antes da confirmação do último passo — abandonar não pode deixar rastro.

Título: **Angebot erfassen**

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

1. **Passo 1 `trades`** — Gewerk: campos: `name`, `trade`, `budget`, `start`, `end`
2. **Passo 2 `offers`** — Angebot: campos: `company`, `tradeId`, `contact`, `amount`, `received`, `validUntil`, `state`, `note`
   somente quando: `Boolean(drafts.trades?.name)`
3. **Passo 3** — Prüfen: resumo gerado a partir do esquema

Tela final: “Erfasst. Die Abweichung zum Budget rechnet sich mit.”

## Padrões

Defina em `DEFAULT_SETTINGS`, `DEFAULT_COLORS` e `DEFAULT_HOME` em `src/app.jsx`.

- Título: **Sanierung**
- Subtítulo: Gewerke, Angebote und wo das Budget steht
- Nome do arquivo: `sanierung`
- Versão: `1.0`
- Idioma da interface: `de`
- Abre como: ferramenta completa
- Cores: `accent` #6b4a8c · `band` #241b2e · `flag` #b4442e · `ok` #3f7a5c · `pending` #c08a12

## Página inicial

O aplicativo abre com este texto. É um subconjunto pequeno de Markdown: títulos, listas, citações, negrito, itálico, código embutido e links. Use-o 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.
```

## Dados de demonstração

Acrescente Gewerke: 10, Angebote: 17 registros de demonstração realistas para que o arquivo não esteja vazio ao abrir. Invente-os no estilo dos campos acima; são ilustração, não os dados do usuário. Diga a ele que os próprios dados entram por **Import CSV → replace all**.

## Pronto quando

- `npm run build` produz um único `dist/index.html` autocontido
- `npm test` passa
- o arquivo abre com duplo clique e mostra os registros de demonstração
- os campos calculados mostram valores e as regras recusam um registro que as viole
- as configurações, cores e página inicial correspondem à especificação acima

## Antes de entregar

Decida você, em vez de deixar para o destinatário: defina `copyright` para quem é dono da ferramenta, substitua o link do cabeçalho que aponta para o repositório do openToolbox e desligue `examplePrompts` se o destinatário apenas registra dados. Mencione o contador de aberturas (Configurações → Segurança) na entrega.

---

*Gerado a partir de `examples/renovation-quotes.domain.js`, o código real da [demo ao vivo](https://m-dohmen.github.io/openToolbox/demos/renovation-quotes/). Gerar de novo com `npm run prompts`.*

*Todos os dados da demo são inventados. Ela ilustra a estrutura de uma ferramenta assim — não é aconselhamento jurídico nem prova de conformidade.*
