<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Construis-moi cet outil: Sanierung

*Tout ce qui suit est la spécification fonctionnelle. Suis-la telle quelle ; là où elle se tait, tranche toi-même et dis ce que tu as supposé.*

## Point de départ

Utilise le modèle **openToolbox** : https://github.com/m-dohmen/openToolbox. Lis d’abord `AGENTS.md` dans ce dépôt — il fait autorité sur la forme du schéma, les types de champs et les règles qui cassent une construction en fichier unique. Tout le métier tient dans un seul fichier, `src/domain.js`.

> Si le skill openToolbox est installé (`claude plugin install opentoolbox@opentoolbox`), il suffit de coller ce fichier — il récupère le modèle lui-même.

## Le problème traité

Quelqu’un rénove une maison. Pour chaque corps de métier il demande trois devis : l’un n’arrive jamais, l’autre coûte le double du prévu, et trois mois plus tard personne ne se souvient pourquoi on a retenu celui du milieu. À la fin vient la question que tout maître d’ouvrage pose trop tard : **sommes-nous encore dans le budget ?** La même forme qu’un portefeuille de projets, mais privée, et avec des délais de validité qui expirent en silence.

## Ce qui doit exister à la fin

Un seul fichier HTML autonome, ouvert par double-clic, sans serveur ni installation. Le fichier est aussi la base de données : enregistrer écrit un nouveau HTML avec les enregistrements intégrés.

## Types d’enregistrement

Clé d’entité `trades` — un enregistrement est **Gewerk**, plusieurs sont **Gewerke**.

**Champs**

| Clé | Libellé | Type | Détail |
| --- | --- | --- | --- |
| `name` | Gewerk | text | obligatoire |
| `trade` | Kategorie | enum | l’une de : Rohbau · Dach · Fenster · Elektro · Sanitär/Heizung · Estrich · Trockenbau · Maler · Boden · Außenanlage |
| `phase` | Stand | enum | l’une de : noch nicht angefragt · Angebote eingeholt · beauftragt · in Ausführung · abgenommen |
| `budget` | Budget (€) | number | en-tête de tableau `Budget` |
| `final` | Schlussrechnung (€) | number | en-tête de tableau `Schluss` |
| `start` | Geplanter Beginn | date | en-tête de tableau `Beginn` |
| `end` | Geplantes Ende | date | en-tête de tableau `Ende` |
| `note` | Notiz | text | multiligne |
| `awarded` | Beauftragt (€) | computed | calculé, jamais stocké, en-tête de tableau `Auftrag` |
| `variance` | Abweichung zum Budget (€) | computed | calculé, jamais stocké, en-tête de tableau `± €` |
| `offerCount` | Angebote | computed | calculé, jamais stocké, en-tête de tableau `Ang.` |

**Présentation**

- Colonne principale : `name`
- Deuxième ligne en dessous : `trade`
- Colonnes du tableau, dans cet ordre : `name`, `trade`, `phase`, `budget`, `awarded`, `variance`, `start`
- Filtres de la barre latérale : `trade`, `phase`
- Totalisé dans l’aperçu : `budget`
- Ne compte plus comme ouvert quand : `r.phase === 'abgenommen'`
- Signalé en rouge quand : `r.phase === 'noch nicht angefragt' && r.start && r.start < iso(30)`

**Champs calculés**

Ils sont dérivés à chaque affichage et jamais écrits dans l’enregistrement : une dérivation stockée devient fausse dès qu’une de ses sources change.

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

**Règles de validation**

Conditions entre champs. Elles doivent s’appliquer en un seul endroit, afin que le formulaire, l’import CSV et les propositions de l’IA passent par le même contrôle.

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

---

Clé d’entité `offers` — un enregistrement est **Angebot**, plusieurs sont **Angebote**.

**Champs**

| Clé | Libellé | Type | Détail |
| --- | --- | --- | --- |
| `company` | Firma | text | obligatoire |
| `tradeId` | Gewerk | reference | obligatoire, référence vers `trades` |
| `contact` | Ansprechpartner | text | en-tête de tableau `Kontakt` |
| `amount` | Summe brutto (€) | number | en-tête de tableau `€` |
| `received` | Eingegangen am | date | en-tête de tableau `Eingang` |
| `validUntil` | Bindefrist bis | date | en-tête de tableau `Bindefrist` |
| `state` | Status | enum | l’une de : angefragt · liegt vor · nachverhandelt · abgelehnt · beauftragt |
| `pdf` | Angebot als Datei | attachment | fichier joint, stocké dans l’enregistrement, en-tête de tableau `PDF` |
| `note` | Notiz | text | multiligne |
| `perBudget` | Anteil am Budget | computed | calculé, jamais stocké, en-tête de tableau `% Budget` |

**Présentation**

- Colonne principale : `company`
- Colonnes du tableau, dans cet ordre : `company`, `tradeId`, `amount`, `perBudget`, `received`, `validUntil`, `state`
- Filtres de la barre latérale : `state`
- Totalisé dans l’aperçu : `amount`
- Ne compte plus comme ouvert quand : `r.state === 'beauftragt' || r.state === 'abgelehnt'`
- Signalé en rouge quand : `r.state !== 'abgelehnt' && r.state !== 'beauftragt' && r.validUntil && r.validUntil < iso(0)`

**Champs calculés**

Ils sont dérivés à chaque affichage et jamais écrits dans l’enregistrement : une dérivation stockée devient fausse dès qu’une de ses sources change.

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

**Règles de validation**

Conditions entre champs. Elles doivent s’appliquer en un seul endroit, afin que le formulaire, l’import CSV et les propositions de l’IA passent par le même contrôle.

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

---

## Tableau de bord

Tuiles sur l’ensemble des enregistrements, pas sur la vue filtrée.

- Un nombre : **Budget gesamt** — `budget` (Budget (€)) · „€ über alle Gewerke“
- Un nombre : **Beauftragt** — `amount` (Summe brutto (€)) (uniquement les enregistrements correspondant à un filtre): `r.state === 'beauftragt'` · „€ bereits vergeben“
- Un nombre : **Nicht angefragt** — le nombre d’enregistrements (uniquement les enregistrements correspondant à un filtre): `r.phase === 'noch nicht angefragt'` · „Gewerke ohne Angebot“
- Un anneau par valeur de `phase`
- Barres par valeur de `trade`, mesurant `budget` (Budget (€)) — **Budget je Kategorie (€)**
- Barres par valeur de `state`, mesurant le nombre d’enregistrements — **Angebote nach Status**

## Saisie guidée

Une courte séquence d’étapes pour quelqu’un qui doit signaler une chose et ne connaît pas l’outil. Rien n’est écrit avant la validation de la dernière étape : abandonner ne doit rien laisser.

Titre : **Angebot erfassen**

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

1. **Étape 1 `trades`** — Gewerk: champs : `name`, `trade`, `budget`, `start`, `end`
2. **Étape 2 `offers`** — Angebot: champs : `company`, `tradeId`, `contact`, `amount`, `received`, `validUntil`, `state`, `note`
   uniquement si : `Boolean(drafts.trades?.name)`
3. **Étape 3** — Prüfen: récapitulatif généré à partir du schéma

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

## Valeurs par défaut

À définir dans `DEFAULT_SETTINGS`, `DEFAULT_COLORS` et `DEFAULT_HOME` dans `src/app.jsx`.

- Titre: **Sanierung**
- Sous-titre: Gewerke, Angebote und wo das Budget steht
- Nom de fichier: `sanierung`
- Version: `1.0`
- Langue de l’interface: `de`
- S’ouvre en: outil complet
- Couleurs: `accent` #6b4a8c · `band` #241b2e · `flag` #b4442e · `ok` #3f7a5c · `pending` #c08a12

## Page d’accueil

L’application s’ouvre sur ce texte. C’est un sous-ensemble de Markdown : titres, listes, citations, gras, italique, code en ligne et liens. À reprendre tel quel :

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

## Données de démonstration

Ajoute Gewerke: 10, Angebote: 17 enregistrements de démonstration réalistes pour que le fichier ne soit pas vide à l’ouverture. Invente-les dans l’esprit des champs ci-dessus ; ce sont des illustrations, pas les données de l’utilisateur. Dis-lui que ses propres données entrent par **Import CSV → replace all**.

## Terminé quand

- `npm run build` produit un seul `dist/index.html` autonome
- `npm test` passe
- le fichier s’ouvre par double-clic et affiche les enregistrements de démonstration
- les champs calculés affichent des valeurs et les règles refusent un enregistrement fautif
- les réglages, les couleurs et la page d’accueil correspondent à la spécification ci-dessus

## Avant de la livrer

Tranche toi-même plutôt que de laisser le destinataire décider : mets `copyright` au nom du propriétaire de l’outil, remplace le lien d’en-tête pointant vers le dépôt openToolbox, et désactive `examplePrompts` si le destinataire ne fait que saisir des données. Mentionne le compteur d’ouvertures (Réglages → Sécurité) à la livraison.

---

*Généré à partir de `examples/renovation-quotes.domain.js`, le code réel de la [démo en ligne](https://m-dohmen.github.io/openToolbox/demos/renovation-quotes/). Régénérer avec `npm run prompts`.*

*Toutes les données de la démo sont inventées. Elle illustre la structure d’un tel outil — ce n’est ni un conseil juridique ni une preuve de conformité.*
