<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# 请帮我做这个工具: Sanierung

*下面是完整的功能需求。请照此实现；需求没说到的地方由你判断，并说明你的假设。*

## 从哪里开始

使用 **openToolbox** 模板：https://github.com/m-dohmen/openToolbox。先读该仓库里的 `AGENTS.md` —— 它是 schema 形态、字段类型以及「哪些做法会破坏单文件构建」的权威说明。所有业务相关的内容只写进一个文件：`src/domain.js`。

> 如果已安装 openToolbox skill（`claude plugin install opentoolbox@opentoolbox`），直接粘贴本文件即可，它会自己去取模板。

## 它解决的问题

有人在翻修房子。每个工种都问三家报价：一家永远不回，一家贵得离谱，三个月后没人记得当初为什么选了中间那家。最后浮出那个所有业主都问得太晚的问题：**我们还在预算内吗？** 结构和项目组合是一样的，只是发生在私人生活里，而且多了会悄悄过期的报价有效期。

## 最终必须交付什么

一个自包含的 HTML 文件，双击即可打开，无需服务器、无需安装。文件本身就是数据库：保存会写出一个新的 HTML 文件，数据嵌在其中。

## 记录类型

实体键 `trades` —— 一条记录是 **Gewerk**，多条是 **Gewerke**。

**字段**

| 键 | 标签 | 类型 | 说明 |
| --- | --- | --- | --- |
| `name` | Gewerk | text | 必填 |
| `trade` | Kategorie | enum | 取值之一：Rohbau · Dach · Fenster · Elektro · Sanitär/Heizung · Estrich · Trockenbau · Maler · Boden · Außenanlage |
| `phase` | Stand | enum | 取值之一：noch nicht angefragt · Angebote eingeholt · beauftragt · in Ausführung · abgenommen |
| `budget` | Budget (€) | number | 表头 `Budget` |
| `final` | Schlussrechnung (€) | number | 表头 `Schluss` |
| `start` | Geplanter Beginn | date | 表头 `Beginn` |
| `end` | Geplantes Ende | date | 表头 `Ende` |
| `note` | Notiz | text | 多行 |
| `awarded` | Beauftragt (€) | computed | 计算得出，从不存储, 表头 `Auftrag` |
| `variance` | Abweichung zum Budget (€) | computed | 计算得出，从不存储, 表头 `± €` |
| `offerCount` | Angebote | computed | 计算得出，从不存储, 表头 `Ang.` |

**呈现方式**

- 主列：`name`
- 主列下方的第二行：`trade`
- 表格列，按此顺序：`name`, `trade`, `phase`, `budget`, `awarded`, `variance`, `start`
- 侧栏筛选：`trade`, `phase`
- 在概览中求和：`budget`
- 不再计为未完成的条件：`r.phase === 'abgenommen'`
- 标红的条件：`r.phase === 'noch nicht angefragt' && r.start && r.start < iso(30)`

**计算字段**

每次渲染时求值，绝不写回记录 —— 一旦某个输入变化，存下来的派生值当场就是错的。

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

**校验规则**

跨字段的条件。必须只在一处生效，使表单、CSV 导入和 AI 提出的改动都走同一套检查。

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

---

实体键 `offers` —— 一条记录是 **Angebot**，多条是 **Angebote**。

**字段**

| 键 | 标签 | 类型 | 说明 |
| --- | --- | --- | --- |
| `company` | Firma | text | 必填 |
| `tradeId` | Gewerk | reference | 必填, 引用 `trades` |
| `contact` | Ansprechpartner | text | 表头 `Kontakt` |
| `amount` | Summe brutto (€) | number | 表头 `€` |
| `received` | Eingegangen am | date | 表头 `Eingang` |
| `validUntil` | Bindefrist bis | date | 表头 `Bindefrist` |
| `state` | Status | enum | 取值之一：angefragt · liegt vor · nachverhandelt · abgelehnt · beauftragt |
| `pdf` | Angebot als Datei | attachment | 上传的文件，存放在记录内, 表头 `PDF` |
| `note` | Notiz | text | 多行 |
| `perBudget` | Anteil am Budget | computed | 计算得出，从不存储, 表头 `% Budget` |

**呈现方式**

- 主列：`company`
- 表格列，按此顺序：`company`, `tradeId`, `amount`, `perBudget`, `received`, `validUntil`, `state`
- 侧栏筛选：`state`
- 在概览中求和：`amount`
- 不再计为未完成的条件：`r.state === 'beauftragt' || r.state === 'abgelehnt'`
- 标红的条件：`r.state !== 'abgelehnt' && r.state !== 'beauftragt' && r.validUntil && r.validUntil < iso(0)`

**计算字段**

每次渲染时求值，绝不写回记录 —— 一旦某个输入变化，存下来的派生值当场就是错的。

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

**校验规则**

跨字段的条件。必须只在一处生效，使表单、CSV 导入和 AI 提出的改动都走同一套检查。

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

---

## 仪表板

统计整个记录集，而不是筛选后的视图。

- 一个数字：**Budget gesamt** — `budget` (Budget (€)) · „€ über alle Gewerke“
- 一个数字：**Beauftragt** — `amount` (Summe brutto (€))（仅符合筛选条件的记录）: `r.state === 'beauftragt'` · „€ bereits vergeben“
- 一个数字：**Nicht angefragt** — 记录条数（仅符合筛选条件的记录）: `r.phase === 'noch nicht angefragt'` · „Gewerke ohne Angebot“
- 按 `phase` 的取值分组的环形图
- 按 `trade` 的取值分组的条形，度量 `budget` (Budget (€)) — **Budget je Kategorie (€)**
- 按 `state` 的取值分组的条形，度量 记录条数 — **Angebote nach Status**

## 引导式录入

给「只需报告一件事、并不熟悉这个工具」的人用的一串短步骤。在最后一步确认之前不写入任何数据 —— 中途放弃必须不留痕迹。

标题：**Angebot erfassen**

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

1. **步骤 1 `trades`** — Gewerk: 字段：`name`, `trade`, `budget`, `start`, `end`
2. **步骤 2 `offers`** — Angebot: 字段：`company`, `tradeId`, `contact`, `amount`, `received`, `validUntil`, `state`, `note`
   仅当：`Boolean(drafts.trades?.name)`
3. **步骤 3** — Prüfen: 由 schema 自动生成的摘要

结束页：“Erfasst. Die Abweichung zum Budget rechnet sich mit.”

## 默认设置

在 `src/app.jsx` 的 `DEFAULT_SETTINGS`、`DEFAULT_COLORS` 和 `DEFAULT_HOME` 中设置。

- 标题: **Sanierung**
- 副标题: Gewerke, Angebote und wo das Budget steht
- 文件名: `sanierung`
- 版本: `1.0`
- 界面语言: `de`
- 打开方式: 完整工具
- 配色: `accent` #6b4a8c · `band` #241b2e · `flag` #b4442e · `ok` #3f7a5c · `pending` #c08a12

## 起始页

应用以这段文字作为起始页。它是一个很小的 Markdown 子集：标题、列表、引用、加粗、斜体、行内代码和链接。请原样使用：

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

## 示例数据

添加 Gewerke: 10, Angebote: 17 条真实感的示例记录，使文件初次打开时不为空。按上面的字段风格自行编写；它们只是示例，不是用户的数据。告诉用户，他自己的数据通过 **Import CSV → replace all** 导入。

## 完成的标准

- `npm run build` 产出单个自包含的 `dist/index.html`
- `npm test` 通过
- 双击可打开，并显示示例记录
- 计算字段有值，规则会拒绝违反它们的记录
- 设置、配色和起始页与上面的规格一致

## 交付之前

这些由你决定，不要留给接收方：把 `copyright` 设为工具的归属方，替换指向 openToolbox 仓库的顶栏链接，若接收方只录入数据就关闭 `examplePrompts`。交付时主动说明使用计数器（设置 → 安全）。

---

*由 `examples/renovation-quotes.domain.js` 生成 —— 即[在线演示](https://m-dohmen.github.io/openToolbox/demos/renovation-quotes/)的真实源码。重新生成：`npm run prompts`。*

*演示中的数据均为虚构。它展示的是这类工具的结构，不构成法律建议，也不能作为合规证明。*
