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

*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é

Un cabinet mène plusieurs missions clients en parallèle. Chacune a un budget, un responsable, une phase et des jalons qui lui appartiennent. La question qui revient à chaque comité de pilotage est de savoir si la mission tient encore dans son budget — et la réponse est généralement reconstituée à la main depuis un tableur auquel personne ne se fie.

## 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é `projects` — un enregistrement est **project**, plusieurs sont **projects**.

**Champs**

| Clé | Libellé | Type | Détail |
| --- | --- | --- | --- |
| `name` | Project | text | obligatoire |
| `client` | Client | text | — |
| `lead` | Engagement lead | text | en-tête de tableau `Lead` |
| `phase` | Phase | enum | l’une de : Initiation · Delivery · Rollout · Closed |
| `risk` | Risk | enum | l’une de : low · medium · high |
| `budget` | Budget in kEUR | number | en-tête de tableau `Budget` |
| `spent` | Spent in kEUR | number | en-tête de tableau `Spent` |
| `start` | Start | date | — |
| `end` | Planned end | date | en-tête de tableau `End` |
| `variance` | Budget left | computed | calculé, jamais stocké, en-tête de tableau `Left` |
| `note` | Note | text | multiligne |

**Présentation**

- Colonne principale : `name`
- Deuxième ligne en dessous : `client`
- Colonnes du tableau, dans cet ordre : `name`, `lead`, `phase`, `risk`, `budget`, `variance`, `end`
- Filtres de la barre latérale : `phase`, `risk`
- Totalisé dans l’aperçu : `budget`
- Ne compte plus comme ouvert quand : `r.phase === 'Closed'`
- Signalé en rouge quand : `r.phase !== 'Closed' && r.end && r.end < today()`

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

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

**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 !== 'Initiation'` → **Alors** `lead`
  **Message:** „A project past initiation needs an engagement lead.“
- **Quand** `r.start && r.end` → **Alors** `r.end >= r.start`
  **Message:** „The planned end cannot be before the start.“

**Mesures**

Déclarées sur cette entité à partir d’un catalogue fermé : comptage, somme et moyenne sur des champs numériques. Calculées au rendu, jamais stockées.

*Le cadre formate les moyennes avec deux décimales dans la notation décimale de la langue de l’interface, rejette les déclarations invalides en les nommant au chargement au lieu de les masquer, et un clic sur une tuile mène à la liste de cette entité, sans filtre dans cette version.*

- **Running projects** — le nombre d’enregistrements (uniquement les enregistrements correspondant à un filtre): `r.phase !== 'Closed'` · „not yet closed“
- **Spent so far** — la somme de `spent` (Spent in kEUR) · „kEUR, all projects“
- **Average budget** — la moyenne de `budget` (Budget in kEUR) · „kEUR per project“
- **Budget left** — la somme de `variance` (Budget left) · „kEUR remaining, computed“

---

Clé d’entité `milestones` — un enregistrement est **milestone**, plusieurs sont **milestones**.

**Champs**

| Clé | Libellé | Type | Détail |
| --- | --- | --- | --- |
| `title` | Milestone | text | obligatoire |
| `projectId` | Project | reference | obligatoire, référence vers `projects` |
| `owner` | Owner | text | — |
| `due` | Due date | date | en-tête de tableau `Due` |
| `status` | Status | enum | l’une de : open · in progress · waiting · done |
| `effort` | Effort in days | number | en-tête de tableau `D` |
| `daysLeft` | Days left | computed | calculé, jamais stocké, en-tête de tableau `Left` |
| `note` | Note | text | multiligne |

**Présentation**

- Colonne principale : `title`
- Colonnes du tableau, dans cet ordre : `title`, `projectId`, `owner`, `due`, `daysLeft`, `status`, `effort`
- Filtres de la barre latérale : `status`
- Totalisé dans l’aperçu : `effort`
- Ne compte plus comme ouvert quand : `r.status === 'done'`
- Signalé en rouge quand : `r.status !== 'done' && r.due && r.due < today()`

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

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

**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.status !== 'open'` → **Alors** `owner`
  **Message:** „A milestone that has started needs an owner.“

**Mesures**

Déclarées sur cette entité à partir d’un catalogue fermé : comptage, somme et moyenne sur des champs numériques. Calculées au rendu, jamais stockées.

*Le cadre formate les moyennes avec deux décimales dans la notation décimale de la langue de l’interface, rejette les déclarations invalides en les nommant au chargement au lieu de les masquer, et un clic sur une tuile mène à la liste de cette entité, sans filtre dans cette version.*

- **Milestones in progress** — le nombre d’enregistrements (uniquement les enregistrements correspondant à un filtre): `r.status === 'in progress'`
- **Average effort** — la moyenne de `effort` (Effort in days) · „days per milestone“

---

## Tableau de bord

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

- Un nombre : **Portfolio budget** — `budget` (Budget in kEUR) (uniquement les enregistrements correspondant à un filtre): `r.phase !== 'Closed'` · „kEUR, running projects“
- Un nombre : **High risk** — le nombre d’enregistrements (uniquement les enregistrements correspondant à un filtre): `r.risk === 'high'` · „projects needing attention“
- Un nombre : **Overdue milestones** — le nombre d’enregistrements (uniquement les enregistrements correspondant à un filtre): `r.status !== 'done' && r.due && r.due < today()` · „across all projects“
- Un anneau par valeur de `phase`
- Barres par valeur de `phase`, mesurant `budget` (Budget in kEUR) — **Budget by phase**
- Un anneau par valeur de `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 : **Add an engagement**

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

1. **Étape 1 `projects`** — Engagement: champs : `name`, `client`, `lead`, `phase`, `risk`, `budget`, `start`, `end`
2. **Étape 2 `milestones`** — First milestone: champs : `title`, `projectId`, `owner`, `due`, `status`, `effort`
   uniquement si : `drafts.projects?.phase !== 'Initiation'`
3. **Étape 3 `milestones`** — More milestones: téléversement CSV, alimentant la même session
   uniquement si : `Boolean(drafts.projects?.name)`
4. **Étape 4** — Check: récapitulatif généré à partir du schéma

Écran final : « The engagement is in the file. »

## Valeurs par défaut

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

- Titre: **Project portfolio**
- Sous-titre: Engagements, milestones and where the budget stands
- Nom de fichier: `project-portfolio`
- Version: `2.1`
- Langue de l’interface: `en`
- S’ouvre en: outil complet
- Couleurs: `accent` #0e7c86 · `band` #16202b · `flag` #c2521b · `ok` #2e7d5b · `pending` #d19a0a

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

## Données de démonstration

Ajoute projects: 7, milestones: 13 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/portfolio.domain.js`, le code réel de la [démo en ligne](https://m-dohmen.github.io/openToolbox/demos/portfolio/). 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é.*
