<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Build me this tool: Sanierung

*Everything below is the functional specification. Follow it as written; where it is silent, use your judgement and say what you assumed.*

## Start here

Use the **openToolbox** template: https://github.com/m-dohmen/openToolbox. Read `AGENTS.md` in that repository first — it is the authority on the schema shape, the field types and the rules that break a single-file build. Everything domain-specific goes into one file, `src/domain.js`.

> If you have the openToolbox skill installed (`claude plugin install opentoolbox@opentoolbox`), just paste this file — it fetches the template itself.

## The problem this solves

Someone renovates a house. For each trade they collect three quotes: one never arrives, one is twice what they expected, and three months later nobody remembers why they went with the middle one. In the end comes the question every client asks too late: **are we still inside the budget?** The same shape as a project portfolio, but private, and with binding periods that quietly expire.

## What it has to be at the end

One self-contained HTML file, opened by double-click, no server and no installation. The file is also the database: saving writes a new HTML file with the records embedded in it.

## Record types

Entity key `trades` — one record is a **Gewerk**, several are **Gewerke**.

**Fields**

| Key | Label | Type | Detail |
| --- | --- | --- | --- |
| `name` | Gewerk | text | required |
| `trade` | Kategorie | enum | one of: Rohbau · Dach · Fenster · Elektro · Sanitär/Heizung · Estrich · Trockenbau · Maler · Boden · Außenanlage |
| `phase` | Stand | enum | one of: noch nicht angefragt · Angebote eingeholt · beauftragt · in Ausführung · abgenommen |
| `budget` | Budget (€) | number | table header `Budget` |
| `final` | Schlussrechnung (€) | number | table header `Schluss` |
| `start` | Geplanter Beginn | date | table header `Beginn` |
| `end` | Geplantes Ende | date | table header `Ende` |
| `note` | Notiz | text | multi-line |
| `awarded` | Beauftragt (€) | computed | calculated, never stored, table header `Auftrag` |
| `variance` | Abweichung zum Budget (€) | computed | calculated, never stored, table header `± €` |
| `offerCount` | Angebote | computed | calculated, never stored, table header `Ang.` |

**Presentation**

- Headline column: `name`
- Second line under the headline: `trade`
- Table columns, in this order: `name`, `trade`, `phase`, `budget`, `awarded`, `variance`, `start`
- Sidebar filters: `trade`, `phase`
- Summed in the overview: `budget`
- No longer counts as open when: `r.phase === 'abgenommen'`
- Flagged red when: `r.phase === 'noch nicht angefragt' && r.start && r.start < iso(30)`

**Calculated fields**

These are derived on every render and never written into the record — a stored derivation goes stale the moment one of its inputs changes.

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

**Validation rules**

Conditions between fields. They must be enforced in one place so that the edit form, the CSV import and anything the AI proposes all pass through the same check.

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

---

Entity key `offers` — one record is a **Angebot**, several are **Angebote**.

**Fields**

| Key | Label | Type | Detail |
| --- | --- | --- | --- |
| `company` | Firma | text | required |
| `tradeId` | Gewerk | reference | required, reference to `trades` |
| `contact` | Ansprechpartner | text | table header `Kontakt` |
| `amount` | Summe brutto (€) | number | table header `€` |
| `received` | Eingegangen am | date | table header `Eingang` |
| `validUntil` | Bindefrist bis | date | table header `Bindefrist` |
| `state` | Status | enum | one of: angefragt · liegt vor · nachverhandelt · abgelehnt · beauftragt |
| `pdf` | Angebot als Datei | attachment | uploaded file, stored in the record, table header `PDF` |
| `note` | Notiz | text | multi-line |
| `perBudget` | Anteil am Budget | computed | calculated, never stored, table header `% Budget` |

**Presentation**

- Headline column: `company`
- Table columns, in this order: `company`, `tradeId`, `amount`, `perBudget`, `received`, `validUntil`, `state`
- Sidebar filters: `state`
- Summed in the overview: `amount`
- No longer counts as open when: `r.state === 'beauftragt' || r.state === 'abgelehnt'`
- Flagged red when: `r.state !== 'abgelehnt' && r.state !== 'beauftragt' && r.validUntil && r.validUntil < iso(0)`

**Calculated fields**

These are derived on every render and never written into the record — a stored derivation goes stale the moment one of its inputs changes.

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

**Validation rules**

Conditions between fields. They must be enforced in one place so that the edit form, the CSV import and anything the AI proposes all pass through the same check.

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

---

## Dashboard

Tiles over the whole record set, not the filtered view.

- A single number: **Budget gesamt** — `budget` (Budget (€)) · „€ über alle Gewerke“
- A single number: **Beauftragt** — `amount` (Summe brutto (€)) (only records matching a filter): `r.state === 'beauftragt'` · „€ bereits vergeben“
- A single number: **Nicht angefragt** — the record count (only records matching a filter): `r.phase === 'noch nicht angefragt'` · „Gewerke ohne Angebot“
- A ring per value of `phase`
- Bars per value of `trade`, measuring `budget` (Budget (€)) — **Budget je Kategorie (€)**
- Bars per value of `state`, measuring the record count — **Angebote nach Status**

## Guided entry

A short sequence of steps for someone who has to report one thing and does not know the tool. Nothing is written until the last step is confirmed — abandoning it must leave nothing behind.

Title: **Angebot erfassen**

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

1. **Step 1 `trades`** — Gewerk: fields: `name`, `trade`, `budget`, `start`, `end`
2. **Step 2 `offers`** — Angebot: fields: `company`, `tradeId`, `contact`, `amount`, `received`, `validUntil`, `state`, `note`
   shown only when: `Boolean(drafts.trades?.name)`
3. **Step 3** — Prüfen: summary generated from the schema

Closing screen: “Erfasst. Die Abweichung zum Budget rechnet sich mit.”

## Defaults

Set these in `DEFAULT_SETTINGS`, `DEFAULT_COLORS` and `DEFAULT_HOME` in `src/app.jsx`.

- Title: **Sanierung**
- Subtitle: Gewerke, Angebote und wo das Budget steht
- File name: `sanierung`
- Version: `1.0`
- Interface language: `de`
- Opens as: the full tool
- Colours: `accent` #6b4a8c · `band` #241b2e · `flag` #b4442e · `ok` #3f7a5c · `pending` #c08a12

## Start page

The app opens on this text. It is a small Markdown subset — headings, lists, quotes, bold, italic, inline code and links. Use it verbatim:

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

## Demo data

Add Gewerke: 10, Angebote: 17 realistic demo records so the file is not empty on first open. Invent them in the style of the examples above; they are illustration, not the user’s data. Tell the user their own data goes in through **Import CSV → replace all**.

## Done when

- `npm run build` produces one self-contained `dist/index.html`
- `npm test` passes
- the file opens by double-click and shows the demo records
- the calculated fields show values, and the rules refuse a record that violates them
- the settings, colours and start page match the specification above

## Before handing it over

Decide these rather than leaving them to the recipient: set `copyright` to whoever owns the tool, replace the header link that points at the openToolbox repository, and switch `examplePrompts` off if the recipient only enters data. Mention the usage counter (Settings → Security) at handover rather than letting someone find it in a network log.

---

*Generated from `examples/renovation-quotes.domain.js` — the working source of the [live demo](https://m-dohmen.github.io/openToolbox/demos/renovation-quotes/). Regenerate with `npm run prompts`.*

*All data in the demo is invented. It illustrates the structure of such a tool — it is not legal advice and not proof of anyone’s compliance.*
