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

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

## 从哪里开始

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

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

## 它解决的问题

一家咨询公司同时在做多个客户项目。每个项目都有预算、负责人、阶段，以及归属于它的里程碑。每次指导委员会上反复出现的问题是：这个项目还在预算之内吗？而答案通常是从一张没人信得过的表格里手工拼出来的。

## 最终必须交付什么

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

## 记录类型

实体键 `projects` —— 一条记录是 **project**，多条是 **projects**。

**字段**

| 键 | 标签 | 类型 | 说明 |
| --- | --- | --- | --- |
| `name` | Project | text | 必填 |
| `client` | Client | text | — |
| `lead` | Engagement lead | text | 表头 `Lead` |
| `phase` | Phase | enum | 取值之一：Initiation · Delivery · Rollout · Closed |
| `risk` | Risk | enum | 取值之一：low · medium · high |
| `budget` | Budget in kEUR | number | 表头 `Budget` |
| `spent` | Spent in kEUR | number | 表头 `Spent` |
| `start` | Start | date | — |
| `end` | Planned end | date | 表头 `End` |
| `variance` | Budget left | computed | 计算得出，从不存储, 表头 `Left` |
| `note` | Note | text | 多行 |

**呈现方式**

- 主列：`name`
- 主列下方的第二行：`client`
- 表格列，按此顺序：`name`, `lead`, `phase`, `risk`, `budget`, `variance`, `end`
- 侧栏筛选：`phase`, `risk`
- 在概览中求和：`budget`
- 不再计为未完成的条件：`r.phase === 'Closed'`
- 标红的条件：`r.phase !== 'Closed' && r.end && r.end < today()`

**计算字段**

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

- `variance` (Budget left) — `(Number(r.budget) || 0) - (Number(r.spent) || 0)`

**校验规则**

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

- **当** `r.phase !== 'Initiation'` → **则** `lead`
  **提示语:** „A project past initiation needs an engagement lead.“
- **当** `r.start && r.end` → **则** `r.end >= r.start`
  **提示语:** „The planned end cannot be before the start.“

**指标卡片**

在该实体上从封闭目录声明——记录条数、数值字段的求和与平均值。渲染时计算，绝不存储。

*框架以两位小数、按界面语言的小数符号格式化平均值；加载时点名拒绝无效声明，而非静默隐藏；点击卡片会跳转到该实体的列表，本版本中不带筛选。*

- **Running projects** — 记录条数（仅符合筛选条件的记录）: `r.phase !== 'Closed'` · „not yet closed“
- **Spent so far** — `spent` (Spent in kEUR) 之和 · „kEUR, all projects“
- **Average budget** — `budget` (Budget in kEUR) 的平均值 · „kEUR per project“
- **Budget left** — `variance` (Budget left) 之和 · „kEUR remaining, computed“

---

实体键 `milestones` —— 一条记录是 **milestone**，多条是 **milestones**。

**字段**

| 键 | 标签 | 类型 | 说明 |
| --- | --- | --- | --- |
| `title` | Milestone | text | 必填 |
| `projectId` | Project | reference | 必填, 引用 `projects` |
| `owner` | Owner | text | — |
| `due` | Due date | date | 表头 `Due` |
| `status` | Status | enum | 取值之一：open · in progress · waiting · done |
| `effort` | Effort in days | number | 表头 `D` |
| `daysLeft` | Days left | computed | 计算得出，从不存储, 表头 `Left` |
| `note` | Note | text | 多行 |

**呈现方式**

- 主列：`title`
- 表格列，按此顺序：`title`, `projectId`, `owner`, `due`, `daysLeft`, `status`, `effort`
- 侧栏筛选：`status`
- 在概览中求和：`effort`
- 不再计为未完成的条件：`r.status === 'done'`
- 标红的条件：`r.status !== 'done' && r.due && r.due < today()`

**计算字段**

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

- `daysLeft` (Days left):

  ```js
  if (!r.due || r.status === 'done') return ''
  const due = localDateFromIso(r.due)
  if (!due) return ''
  // Both sides as whole local calendar days: Date.UTC on the day
  // components keeps the difference an exact day count, free of the
  // daylight-saving hours that break a plain millisecond division -
  // and of mixing a UTC-midnight constructor with local midnight.
  const now = new Date()
  const days =
    (Date.UTC(due.getFullYear(), due.getMonth(), due.getDate()) -
      Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) /
    86400000
  return days
  ```

**校验规则**

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

- **当** `r.status !== 'open'` → **则** `owner`
  **提示语:** „A milestone that has started needs an owner.“

**指标卡片**

在该实体上从封闭目录声明——记录条数、数值字段的求和与平均值。渲染时计算，绝不存储。

*框架以两位小数、按界面语言的小数符号格式化平均值；加载时点名拒绝无效声明，而非静默隐藏；点击卡片会跳转到该实体的列表，本版本中不带筛选。*

- **Milestones in progress** — 记录条数（仅符合筛选条件的记录）: `r.status === 'in progress'`
- **Average effort** — `effort` (Effort in days) 的平均值 · „days per milestone“

---

## 仪表板

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

- 一个数字：**Portfolio budget** — `budget` (Budget in kEUR)（仅符合筛选条件的记录）: `r.phase !== 'Closed'` · „kEUR, running projects“
- 一个数字：**High risk** — 记录条数（仅符合筛选条件的记录）: `r.risk === 'high'` · „projects needing attention“
- 一个数字：**Overdue milestones** — 记录条数（仅符合筛选条件的记录）: `r.status !== 'done' && r.due && r.due < today()` · „across all projects“
- 按 `phase` 的取值分组的环形图
- 按 `phase` 的取值分组的条形，度量 `budget` (Budget in kEUR) — **Budget by phase**
- 按 `status` 的取值分组的环形图

## 引导式录入

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

标题：**Add an engagement**

> Three steps: the engagement, its first milestone, then a check before anything is written.

1. **步骤 1 `projects`** — Engagement: 字段：`name`, `client`, `lead`, `phase`, `risk`, `budget`, `start`, `end`
2. **步骤 2 `milestones`** — First milestone: 字段：`title`, `projectId`, `owner`, `due`, `status`, `effort`
   仅当：`drafts.projects?.phase !== 'Initiation'`
3. **步骤 3 `milestones`** — More milestones: CSV 上传，并入同一次录入
   仅当：`Boolean(drafts.projects?.name)`
4. **步骤 4** — Check: 由 schema 自动生成的摘要

结束页：“The engagement is in the file.”

## 默认设置

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

- 标题: **Project portfolio**
- 副标题: Engagements, milestones and where the budget stands
- 文件名: `project-portfolio`
- 版本: `2.1`
- 界面语言: `en`
- 打开方式: 完整工具
- 配色: `accent` #0e7c86 · `band` #16202b · `flag` #c2521b · `ok` #2e7d5b · `pending` #d19a0a

## 起始页

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

```markdown
# Project portfolio

A worked example built with **openToolbox**: engagements, their milestones, and where the budget
stands. Everything you see comes out of one file, `src/domain.js`.

## What to try

- **List** — two record types that reference each other, calculated columns, filters that count
- **Search and filter** — the box in the header searches every field of both record types at once,
  and the sidebar narrows by contained text and by number and date ranges; active filters appear
  as removable chips above the table
- **Dashboard** — the same data as tiles, drawn without a charting library, plus a due-date widget
  grouping milestones into overdue, this week and the next 30 days, and metric tiles — running
  projects, spend, averages — computed from the data rather than typed
- **Guided entry** — a short wizard that creates an engagement and its first milestone in one run
- **Merge a file** — reconcile a copy that came back from someone else

> This page is editable in the app itself. In a tool you deliver, put here what the recipients
> need: what it is for, who maintains it, and where to ask.
```

## 示例数据

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

## 完成的标准

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

## 交付之前

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

---

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

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