Initial commit: everythingbutwork MVP
Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets) und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion), Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0. Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 + Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv). Planungs-Artefakte unter _bmad-output/planning-artifacts/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,24 @@
|
||||
# --- Postgres ---
|
||||
DATABASE_URL=postgres://cockpit:cockpit_dev@localhost:55432/cockpit
|
||||
|
||||
# --- Authentik OIDC (Story 1.3) ---
|
||||
# Anlage als „Provider" und „Application" in Authentik unter https://auth.tool-ctrl.de
|
||||
# → siehe docs/operations.md (kommt in Story 1.7)
|
||||
AUTHENTIK_ISSUER=https://auth.tool-ctrl.de/application/o/cockpit/
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
AUTHENTIK_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||
|
||||
# --- Session ---
|
||||
SESSION_COOKIE_NAME=cockpit_session
|
||||
SESSION_TTL_HOURS=24
|
||||
SESSION_COOKIE_SECRET=please-change-me-in-prod-at-least-32-chars
|
||||
|
||||
# --- Cron Bearer (Story 2.6 + 5.1 + 6.3) ---
|
||||
CRON_BEARER_TOKEN=please-change-me-in-prod
|
||||
|
||||
# --- App ---
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
PORT=3000
|
||||
PUBLIC_BASE_URL=http://localhost:5173
|
||||
@@ -0,0 +1,47 @@
|
||||
# Gitea Actions CI für das Cockpit-Repo.
|
||||
# Läuft bei Push und PR.
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.0
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.0
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter web build
|
||||
- run: pnpm --filter api build
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
.turbo/
|
||||
|
||||
# Brand-Assets (binär, im Repo Pull-Quelle: original Designer-Übergabe)
|
||||
ToolCTRL Brand Identity/
|
||||
|
||||
# Lokale Claude-Code-Konfiguration
|
||||
.claude/
|
||||
|
||||
# Lokale Hilfs-Dateien
|
||||
/tmp-seed*.sql
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.next
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
_bmad
|
||||
_bmad-output
|
||||
.claude
|
||||
ToolCTRL Brand Identity
|
||||
pnpm-workspace.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# CONVENTIONS — ToolCTRL everythingbutwork
|
||||
|
||||
> **Bindend** für alle Code-erzeugenden Agenten und Menschen. Spiegelt Architektur-Spec § Implementation Patterns. Pflicht-Lesen vor jeder Story.
|
||||
|
||||
## Naming
|
||||
|
||||
### Datenbank (Drizzle + Postgres)
|
||||
|
||||
- Tabellen: Singular, `snake_case` (englisch) — `user`, `project`, `day_entry`, `todo`. Domain-Begriffe in Spalten dürfen deutsch sein (`abwesend_bis`, `arbeitsmodus`).
|
||||
- Primärschlüssel: immer `id` (UUID v7).
|
||||
- Fremdschlüssel: `<entity>_id` — `user_id`, `project_id`.
|
||||
- Indizes: `idx_<table>_<column>`; unique: `uniq_<table>_<column>`.
|
||||
- Enums: TypeScript-Union in Drizzle + `text` mit `check`-Constraint in Postgres.
|
||||
- Zeitstempel: `created_at`, `updated_at`, `<verb>_at` als `timestamptz`, Default `now()`.
|
||||
|
||||
### tRPC Backend
|
||||
|
||||
- Router-Datei: `apps/api/src/routers/<feature>.ts` — `day-entry.ts`, `project.ts`.
|
||||
- Router-Variable: `<feature>Router` (camelCase) — `dayEntryRouter`.
|
||||
- Procedure-Naming: `<feature>.<verbNoun>` (camelCase) — `dayEntry.checkIn`, `dayEntry.submitReflection`, `project.archive`, `blocker.resolve`.
|
||||
- Reads: `list`, `byId`, `mine`, `forProject`.
|
||||
- Mutations: `create`, `update`, `archive` (statt `delete`), `resolve`, `checkIn`, `submitReflection`.
|
||||
- Procedure-Inputs: **immer** Zod-Schema aus `@cockpit/shared/schemas/<feature>`.
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
- Komponenten: PascalCase. Dateiname matched (`DayEntryForm.tsx`).
|
||||
- Hooks: `use<Name>` camelCase.
|
||||
- Konstanten: `SCREAMING_SNAKE_CASE`.
|
||||
- Route-Pfade: kebab-case, deutsch wo Domain — `/puls`, `/mein-heute`, `/bereich/entwicklung`.
|
||||
- Komponenten-Organisation: **nach Feature**, nicht nach Typ.
|
||||
|
||||
## Struktur
|
||||
|
||||
- Tests **neben** dem Code: `Foo.tsx` ↔ `Foo.test.tsx` (Backend: `foo.ts` ↔ `foo.test.ts`).
|
||||
- E2E: zentral in `apps/web/e2e/<journey>.spec.ts`.
|
||||
- Shared zwischen Frontend + Backend → `packages/shared/`. Backend-only Helper → `apps/api/src/lib/`.
|
||||
- Konfig-Dateien im Repo-Root, pro App nur Overrides.
|
||||
|
||||
## Format
|
||||
|
||||
- JSON-Felder über die Wire: `camelCase` (Drizzle mapt DB-`snake_case` → TS-`camelCase`).
|
||||
- Datumsformat: **ISO 8601 mit Zeitzone**. Reine Werktage `YYYY-MM-DD` (Typ `date`).
|
||||
- Geldbeträge: Cent als `integer` in DB; Frontend formatiert via `Intl.NumberFormat('de-DE')`.
|
||||
- Booleans: echtes `boolean`. Nie `0/1` oder `"yes"/"no"`.
|
||||
- Null vs. Undefined: DB nutzt `null`. Code: `null` = explizit nicht gesetzt, `undefined` = existiert nicht.
|
||||
- IDs in URL: UUIDs (`/projekt/:id`), keine numerischen IDs öffentlich.
|
||||
|
||||
## Errors
|
||||
|
||||
- Backend: `TRPCError` mit klaren Codes — `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `BAD_REQUEST`, `INTERNAL_SERVER_ERROR`.
|
||||
- Message: **immer deutsch**, menschenlesbar. Nicht „NULL constraint violated" sondern „Bitte wähle einen Verantwortlichen aus".
|
||||
- Frontend: zentraler `onError`-Hook in tRPC-Provider zeigt `error.message` als Toast.
|
||||
- Logging: `INTERNAL_SERVER_ERROR` mit Stack → `pino.error`. `BAD_REQUEST` → `pino.info`.
|
||||
|
||||
## State (Frontend)
|
||||
|
||||
- Server-State: **ausschließlich React Query** via tRPC (`isPending`, `isError`, `data`). **Nie** eigene `useState`-Booleans für Server-Loading.
|
||||
- Loading: Skeleton-Komponenten, keine Spinner-Only.
|
||||
- Optimistic Updates für Reflexion, Todo-Toggle, Blocker-Auflösung. Sonst nicht.
|
||||
- Lokaler UI-State: `useState`/`useReducer` in der Komponente. Hochziehen, wenn Sibling-Sharing nötig — kein Context.
|
||||
|
||||
## Audit & Authorization
|
||||
|
||||
- Mutations: `withAudit()`-Middleware um jede schreibende Procedure. Schreibt `audit_event` in derselben Transaktion.
|
||||
- AuditEvent-Naming: `<entity>.<verb>` (lowercase, dot-separated) — `day_entry.reflection_submitted`, `blocker.resolved`.
|
||||
- Procedure-Typen: `publicProcedure`, `protectedProcedure`, `dayEntryVisibleProcedure`, `projectOwnerProcedure`.
|
||||
|
||||
## Werktag / Zeit
|
||||
|
||||
- Server-Zeit: **`Europe/Berlin`**.
|
||||
- Werktage: Mo-Fr (MVP, ohne Feiertage).
|
||||
- **Alle Datumslogik im Backend** — Frontend bekommt fertige Felder (`isWorkday`, `nextWorkday`, `streakDays`).
|
||||
|
||||
## Voice & Tone
|
||||
|
||||
- UI-Strings zentral in `apps/web/src/lib/voice.ts`.
|
||||
- Tonalität: locker, direkt, deutsch, kurze Imperative — „Stark.", „Wie läuft es?", „Du hast heute noch nicht gepingt.".
|
||||
- Keine Corporate-Sprech, keine englischen Floskeln (Fachbegriffe aus Leitfaden erlaubt).
|
||||
- Error-Messages im Backend ebenfalls deutsch.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit & Procedures: **Vitest**.
|
||||
- E2E: **Playwright** auf UJ-1, UJ-2, UJ-5 (CI nur auf Tag/Release).
|
||||
- A11y: `axe-core` in Vitest pro Komponente, `@axe-core/playwright` für E2E, Lighthouse-A11y ≥ 95 im CI.
|
||||
- Tests neben Code (`*.test.ts(x)`).
|
||||
|
||||
## DB-Änderungen
|
||||
|
||||
- **Immer** Drizzle-Migration committen — kein Schema-Drift.
|
||||
- Migration entstehen je Story-Bedarf, nicht upfront.
|
||||
- TypeScript strict, kein `any`. Bei unklarer Form: `unknown` + Zod-Validation.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- ❌ Inline-Zod im Procedure (gehört nach `@cockpit/shared/schemas`).
|
||||
- ❌ Englisches Error-Message.
|
||||
- ❌ snake_case-Komponentennamen.
|
||||
- ❌ Server-Loading via `useState`.
|
||||
- ❌ Drag-and-Drop außerhalb des Sales-Kanban.
|
||||
- ❌ Pulsing-/Spin-Animationen auf dem Puls.
|
||||
- ❌ Auto-Refresh / Background-Polling.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Caddy 2.x — Reverse-Proxy für das Cockpit.
|
||||
# Lokal: http://localhost
|
||||
# Produktion: cockpit.tool-ctrl.de
|
||||
|
||||
{$COCKPIT_HOST:cockpit.tool-ctrl.de} {
|
||||
encode zstd gzip
|
||||
|
||||
# /internal/* darf nur über das interne Netz erreichbar sein.
|
||||
@internal path /internal/*
|
||||
respond @internal 403
|
||||
|
||||
# API + tRPC → Backend
|
||||
@api path /api/* /trpc/*
|
||||
reverse_proxy @api api:3000
|
||||
|
||||
# Static frontend
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# Security-Headers (CSP wird in Story 6.x feinjustiert)
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# ToolCTRL everythingbutwork
|
||||
|
||||
Internes Web-Tool zur täglichen Übersicht über alle Projekte und Bereiche. Ergänzt **Plane** (Tickets) und **Outline** (Doku) als drittes Werkzeug gemäß ToolCTRL Projektmanagement-Leitfaden v3.0.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend:** Vite + React 18 + TypeScript + Tailwind (kommt in Story 1.5)
|
||||
- **Backend:** Fastify + tRPC v11 + Drizzle (kommt in Story 1.4 + 1.2)
|
||||
- **DB:** Postgres 16+ (kommt in Story 1.2)
|
||||
- **Auth:** Authentik OIDC (kommt in Story 1.3)
|
||||
- **Hosting:** Docker + Caddy auf interner ToolCTRL-Infrastruktur
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── web/ # Vite + React SPA
|
||||
│ └── api/ # Fastify Backend
|
||||
├── packages/
|
||||
│ ├── db/ # Drizzle-Schema + Migrationen
|
||||
│ └── shared/ # Zod-Schemas + Konstanten + Typen
|
||||
├── _bmad-output/ # Planungs-Artefakte (PRD, Architektur, UX, Epics)
|
||||
└── CONVENTIONS.md # Bindende Code-Konventionen (Pflicht-Lesen)
|
||||
```
|
||||
|
||||
## Loslegen
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter web dev # Frontend auf http://localhost:5173
|
||||
pnpm --filter api dev # Backend auf http://localhost:3000
|
||||
pnpm typecheck # Alle Workspaces typecheken
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
```
|
||||
|
||||
## Planungs-Artefakte
|
||||
|
||||
- [PRD](./_bmad-output/planning-artifacts/prds/prd-projectmanagement-2026-05-25/prd.md) — 49 Functional Requirements, 16 NFRs
|
||||
- [Architektur](./_bmad-output/planning-artifacts/architecture.md) — Stack, Schnitt, Patterns
|
||||
- [UX-Spec](./_bmad-output/planning-artifacts/ux-design-specification.md) — Brand-Tokens, Komponenten, User Journeys
|
||||
- [Epics & Stories](./_bmad-output/planning-artifacts/epics.md) — 6 Epics, 32 Stories
|
||||
- [Konventionen](./CONVENTIONS.md) — Naming, Struktur, Formate, Errors
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,966 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4]
|
||||
status: 'complete'
|
||||
completedAt: '2026-05-25'
|
||||
inputDocuments:
|
||||
- prds/prd-projectmanagement-2026-05-25/prd.md
|
||||
- prds/prd-projectmanagement-2026-05-25/addendum.md
|
||||
- architecture.md
|
||||
- ux-design-specification.md
|
||||
- prds/prd-projectmanagement-2026-05-25/.decision-log.md
|
||||
workflowType: 'epics-and-stories'
|
||||
project_name: 'ToolCTRL Unternehmens-Cockpit'
|
||||
user_name: 'Dejancosic'
|
||||
date: '2026-05-25'
|
||||
---
|
||||
|
||||
# ToolCTRL Unternehmens-Cockpit - Epic Breakdown
|
||||
|
||||
## Overview
|
||||
|
||||
Dieses Dokument zerlegt das PRD (49 FRs, 16 NFRs), die Architektur (komplett, Vite + React + Fastify + tRPC + Drizzle + Postgres + Authentik) und die UX-Spec (15 Custom-Components, Brand-Tokens, 5 User Journeys) in implementierbare Epics und Stories.
|
||||
|
||||
## Requirements Inventory
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
> 49 FRs aus PRD §6, organisiert in 10 Feature-Gruppen (A–J).
|
||||
|
||||
**A: Unternehmens-Puls (Startseite)**
|
||||
|
||||
- **FR-1** Übersichtsleiste aller Areas (Entwicklung, Vertrieb, Marketing) inkl. aggregiertem Ampelstatus und Anzahl heute-fehlender Updates pro Area.
|
||||
- **FR-1a** Ampel-Aggregation Algorithmus pro Area: Rot wenn ≥ 1 Projekt rot; Gelb wenn ≥ 1 Projekt gelb und kein rotes; Grün wenn alle grün; Grau wenn keine aktiven Projekte. Pflicht-Minimum-Verstöße heben Ampel mindestens auf Gelb.
|
||||
- **FR-2** Bereichsübergreifende Liste aller offenen Blocker, sortiert nach Alter.
|
||||
- **FR-3** Heute-Quote „X von Y Mitarbeitern haben heute gepingt" mit Klick auf Liste der fehlenden Personen.
|
||||
- **FR-4** Anzeige des nächsten Jourfixe-Termins (statisch konfigurierbar, Default Di 11:00).
|
||||
- **FR-5** Pro Bereich definierter Key-Metrics-Katalog:
|
||||
- Entwicklung: # Projekte gesamt / im Plan / gefährdet / blockiert / # überzogene Meilensteine.
|
||||
- Vertrieb: gewichteter Pipeline-Wert (Σ value × probability) / # offene Deals / gewonnene Deals (Quartal, # + EUR) / # offene GTM-Schritte.
|
||||
- Marketing: Reichweite (Periode) / # aktive Kampagnen / Leads / MQLs / Übergaben an Vertrieb.
|
||||
- **FR-6** Klick auf Bereich-Kachel öffnet Bereichs-Detail-View.
|
||||
- **FR-7** Pflicht-Minimum-Übersicht im Puls: Liste aller aktiven Projekte mit roten/gelben Pflicht-Minimum-Punkten.
|
||||
|
||||
**B: Bereichs-Detail Entwicklung**
|
||||
|
||||
- **FR-8** Liste aller aktiven Projekte, gruppiert nach Projektart (Kundenprojekt / Internes Projekt — Leitfaden §1).
|
||||
- **FR-9** Pro Projekt-Kachel: Name, Verantwortliche/r (P1), Ampelstatus + Trend (↗︎/→/↘︎), Deadline-Progress-Bar, # offene Meilensteine, Pflicht-Minimum-Score (z. B. „5/6").
|
||||
- **FR-10** Projekt-Detail mit Kopfzeile (Name, P1, Projektart, Arbeitsmodus, Ampel + Trend), Pflicht-Minimum-Anzeige, Verlinkungen (Outline-Steckbrief, Plane-Board, Risikoliste), optional DoD-Text, Meilenstein-Checkliste, Update-Feed.
|
||||
- **FR-11** Meilensteine als grobe Cockpit-Sicht (Titel, Fälligkeit, Status: offen/erledigt/überzogen). Wahrheit bleibt im Outline-Steckbrief.
|
||||
|
||||
**C: Bereichs-Detail Vertrieb**
|
||||
|
||||
- **FR-12** 5-Spalten Kanban: Lead → Erstgespräch → Demo → Angebot → Abschluss, mit Drag-and-Drop.
|
||||
- **FR-13** Deal-Karten: Name, Verantwortlicher, Wert (€), Stage-Wahrscheinlichkeit (0-100 %), Tage in aktueller Stage.
|
||||
- **FR-14** Vertriebs-Header-KPIs: gewichteter Pipeline-Wert, gewonnene Deals (# + EUR, laufendes Quartal), aktiver Deal-Count, ⌀ Stage-Verweildauer.
|
||||
- **FR-15** Pro Produkt eine sortierbare GTM-Schritt-Liste (Schritt, Verantwortlicher, Status).
|
||||
- **FR-16** Vertrieb-Update-Feed (Filter Bezug = Deal oder Vertrieb-allgemein).
|
||||
|
||||
**D: Bereichs-Detail Marketing**
|
||||
|
||||
- **FR-17** Horizontaler Lead-Funnel mit 5 Stufen (Reichweite → Website → Leads → MQL → Übergabe) + Conversion-%.
|
||||
- **FR-18** Liste aktiver Kampagnen mit Status (Aktiv / Pausiert / Beendet), Reichweite, Verantwortlicher.
|
||||
- **FR-19** Crosslink-Banner Marketing↔Vertrieb: „Marketing hat X Leads übergeben in [Zeitraum], Vertrieb hat daraus Y Deals erzeugt (Z €)".
|
||||
- **FR-20** Marketing-Update-Feed (Filter Bezug = Kampagne oder Marketing-allgemein).
|
||||
|
||||
**E: Tagesrhythmus**
|
||||
|
||||
- **FR-21** „Einstempeln"-Aktion auf Puls (großer CTA), öffnet ToDo-Eingabe für heute.
|
||||
- **FR-22** ToDo-Liste: 1-N Items, pro Item Titel (max. 200 Z) + optionaler Bezug.
|
||||
- **FR-23** Eingestempelt = Tag „begonnen", Person verschwindet aus „heute fehlt"-Liste.
|
||||
- **FR-24** Sichtbarkeit innerhalb des Tages: nur binäre Anwesenheit für Kollegen, ToDo-Inhalte privat. P1 sieht ToDos seiner Projekte.
|
||||
- **FR-25** ToDos editierbar bis zur Reflexion.
|
||||
- **FR-26** Reflexions-Formular: ToDos abhaken/verwerfen (mit Grund), Freitext (max. 1000 Z), Ampel (Grün/Gelb/Rot mit Leitfaden-Semantik), Trend (Pflicht bei Bezug=Projekt), optional Blocker, optional URL.
|
||||
- **FR-27** Reflexion → Tag „voll abgeschlossen", Streak +1.
|
||||
- **FR-28** Reflexion 30 min editierbar; Reflexions-Erinnerung ab konfigurierbarer Zeit (Default 17:00).
|
||||
- **FR-29** Konsolidierter Vortags-Eintrag wird am nächsten Werktag morgen publiziert (sichtbar für Kollegen).
|
||||
- **FR-30** Feed-Sortierung: neueste oben. Pagination 50/Seite.
|
||||
- **FR-31** Reflexion setzt automatisch Ampel + Trend des verlinkten Projekts/Deals/Kampagne.
|
||||
- **FR-32** Rückwirkende Erfassung max. 1 Werktag zurück.
|
||||
- **FR-33** Tages-Status pro Person: 🟢 voll / 🟡 halb / ⚪ nicht angefangen / 🟣 abwesend.
|
||||
|
||||
**F: Streak / Anwesenheit / Disziplin**
|
||||
|
||||
- **FR-34** Pro Person aktuelle Streak (aufeinanderfolgende „volle" Werktage).
|
||||
- **FR-35** „Heute fehlt"-Liste auf Puls — nach 11:00 für nicht-eingestempelte, nach 17:00 für „halb"-Status.
|
||||
- **FR-36** Werktage = Mo-Fr (Feiertage Phase 2).
|
||||
- **FR-37** Person markiert sich für Tag/Zeitraum als „Abwesend" (Urlaub/Krankheit), zählt nicht gegen Streak.
|
||||
|
||||
**G: Pflicht-Minimum-Check (Leitfaden §9)**
|
||||
|
||||
- **FR-38** Pro aktivem Projekt 6-Punkte-Checkliste sichtbar:
|
||||
1. Projektsteckbrief in Outline freigegeben → erfüllt wenn `outline_steckbrief_url` gepflegt UND als „freigegeben" markiert.
|
||||
2. P1 gesetzt.
|
||||
3. Plane-Board mit zerlegter Arbeit → erfüllt wenn `plane_project_url` gepflegt.
|
||||
4. Meilensteine mit groben Terminen → erfüllt wenn ≥ 1 Meilenstein.
|
||||
5. Status-Ampel aktuell → erfüllt wenn letzte Reflexion mit Projektbezug ≤ 7 Werktage alt.
|
||||
6. Abschluss-Retrospektive (nur bei beendeten Projekten).
|
||||
- **FR-39** MVP: Punkte 1, 2, 3, 4 manuell als Cockpit-Checkbox; Punkt 5 automatisch aus Reflexions-Zeitstempel; Punkt 6 im Abschluss-Workflow.
|
||||
- **FR-40** Projekte mit unerfülltem Pflicht-Punkt prominent auf Puls.
|
||||
|
||||
**H: Blocker**
|
||||
|
||||
- **FR-41** Blocker entsteht inline in Reflexion oder via „Neuer Blocker"-Button.
|
||||
- **FR-42** Blocker-Felder: Titel, Beschreibung, Verantwortlicher, Ersteller, Bereich(e), Datum offen, Status.
|
||||
- **FR-43** Blocker erscheint auf Puls, in betroffenen Bereichs-Detail-Views und in Projekt-Detail.
|
||||
- **FR-44** Auflösung mit Auflöser, Datum, Kommentar. Nur P1 des bezogenen Projekts darf auflösen; bei projektlosen Blockern auch Ersteller/Verantwortlicher.
|
||||
|
||||
**I: Projekt-Abschluss**
|
||||
|
||||
- **FR-45** P1 setzt Projekt auf „abgeschlossen".
|
||||
- **FR-46** Beim Abschluss fordert Cockpit Lessons-Learned-URL an.
|
||||
- **FR-47** Abgeschlossene Projekte verschwinden aus Puls, bleiben in Archiv-Sicht pro Bereich.
|
||||
|
||||
**J: Navigation, Suche & Filter**
|
||||
|
||||
- **FR-48** Globale TopNav: Puls / Entwicklung / Vertrieb / Marketing / Mein heute / Eigenes Profil / Archiv.
|
||||
- **FR-49** Volltextsuche → Phase 2; MVP: Filter im Feed nach Bereich/Bezug.
|
||||
|
||||
### NonFunctional Requirements
|
||||
|
||||
> 16 NFRs aus PRD §7.
|
||||
|
||||
**Performance**
|
||||
|
||||
- **NFR-1** Puls-Seite < 1,5 s bei 6-15 Nutzern und 90 Tagen Historie.
|
||||
- **NFR-2** Tagesupdate-Speichern < 500 ms.
|
||||
|
||||
**Verfügbarkeit**
|
||||
|
||||
- **NFR-3** Mo-Fr 8-19: 99 %.
|
||||
- **NFR-4** Wartungsfenster außerhalb Kernzeit kommunizierbar.
|
||||
|
||||
**Security & Privacy**
|
||||
|
||||
- **NFR-5** Alle Routen hinter Authentik-OIDC.
|
||||
- **NFR-6** Keine externe PII in Deal-Karten.
|
||||
- **NFR-7** Audit-Trail für jede mutierende Operation (actor / timestamp / payload).
|
||||
|
||||
**Bedienbarkeit & Visual**
|
||||
|
||||
- **NFR-8** Brand-konform: weiß primär, schwarz sekundär, ToolCTRL Brand Identity.
|
||||
- **NFR-9** Tagesupdate-Schreib-Workflow ≤ 3 Klicks ab Puls.
|
||||
- **NFR-10** Desktop-first, mobil-lesbar (nicht mobil-schreibbar).
|
||||
- **NFR-11** Deutsch durchgängig, keine i18n.
|
||||
|
||||
**Voice & Tone**
|
||||
|
||||
- **NFR-12** Tonalität locker/direkt/deutsch, kurze Imperative ("Stark.", "Wie läuft es?", "Du hast heute noch nicht gepingt.").
|
||||
- **NFR-13** Keine Corporate-Sprech, keine englischen Floskeln (Fachbegriffe aus Leitfaden ausgenommen).
|
||||
|
||||
**Datenhaltung & Erweiterbarkeit**
|
||||
|
||||
- **NFR-14** Postgres als Persistenz.
|
||||
- **NFR-15** Tägliches Backup.
|
||||
- **NFR-16** Datenmodell erweiterbar ohne Migrationen > 2 h.
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
> Aus Architektur-Spec abgeleitete technische Anforderungen.
|
||||
|
||||
- **AR-1** **Starter / Bootstrap**: Custom Monorepo (pnpm-Workspaces) mit `apps/web` (Vite + React + TS + Tailwind), `apps/api` (Fastify + tRPC + Drizzle), `packages/db` (Drizzle-Schema), `packages/shared` (Zod + Types). Erste Story nutzt die in der Architektur §"Initialization Commands" beschriebenen Setup-Befehle.
|
||||
- **AR-2** **Datenmodell** in `packages/db/schema.ts` mit allen 14 Entitäten (User, Area, Project, Milestone, Deal, GtmStep, Campaign, LeadHandover, DayEntry, Todo, Blocker, AbsenceDay, AuditEvent, Session) gemäß Addendum §1.2 und Architektur §Project Structure. UUID v7 als ID-Primary.
|
||||
- **AR-3** **Auth-Flow**: `openid-client` v6+ direkt im Fastify, Authorization Code + PKCE gegen Authentik. Server-Side-Session in Postgres, HttpOnly + SameSite=Lax + Secure Cookie. Default-Bereich aus Authentik-Group-Claim.
|
||||
- **AR-4** **tRPC Procedure-Typen**: `publicProcedure`, `protectedProcedure`, `dayEntryVisibleProcedure` (Sichtbarkeitsfilter pre/post `published_at`), `projectOwnerProcedure` (P1-Check).
|
||||
- **AR-5** **Audit-Middleware** (`withAudit`) um jede schreibende tRPC-Mutation, schreibt parallel in `audit_event`.
|
||||
- **AR-6** **Background-Jobs** via System-Cron → authentifizierter Fastify-Endpoint (`POST /internal/cron/*` mit Bearer-Token):
|
||||
- `publish-day-entries` (tägl. 00:05 Europe/Berlin)
|
||||
- `recompute-pflicht-minimum-5` (stündlich)
|
||||
- `recompute-streaks` (tägl. 00:10)
|
||||
- `pg-dump-backup` (tägl. 02:00)
|
||||
- **AR-7** **Werktag-Logik** zentral in `apps/api/src/lib/werktag.ts` (Mo-Fr Europe/Berlin). Frontend bekommt vorberechnete Werte, niemals eigene Datumsmagie.
|
||||
- **AR-8** **Ampel-Aggregation** in `apps/api/src/lib/ampel.ts` (FR-1a-Algorithmus).
|
||||
- **AR-9** **Pflicht-Minimum-Berechnung** in `apps/api/src/lib/pflicht-minimum.ts`, lazy beim Read.
|
||||
- **AR-10** **Hosting**: Docker-Compose mit `web`-Container (Caddy-served `apps/web/dist`), `api`-Container (Node 22+), `postgres:16`-Container, `caddy`-Container als Reverse-Proxy auf `cockpit.tool-ctrl.de`.
|
||||
- **AR-11** **CI/CD**: Gitea Actions Pipeline (Typecheck, Lint, Vitest, Build, Docker-Image-Push, Deploy-Trigger).
|
||||
- **AR-12** **Tests**: Vitest (Unit + tRPC-Procedures), Playwright (E2E auf UJ-1, UJ-2, UJ-5). Lighthouse-A11y-Score ≥ 95.
|
||||
- **AR-13** **Logging**: strukturierte JSON-Logs via `pino` → stdout.
|
||||
- **AR-14** **Healthcheck**: `GET /api/health` (Postgres-Ping + Authentik-Reachability) für Docker-Healthcheck.
|
||||
- **AR-15** **Konventionen-Datei** `CONVENTIONS.md` im Repo-Root (spiegelt Architektur §Implementation Patterns), Pflicht-Lesen vor Story-Bearbeitung.
|
||||
- **AR-16** **Backup-Strategie**: `pg_dump` täglich auf separates Volume, Retention 14 Tage. Restore-Drill quartalsweise dokumentiert in `docs/operations.md`.
|
||||
|
||||
### UX Design Requirements
|
||||
|
||||
> Aus UX-Spec abgeleitete UI/Visual/A11y-Anforderungen.
|
||||
|
||||
**Brand-Tokens & Visual Foundation**
|
||||
|
||||
- **UX-DR1** Brand-Tokens als CSS-Variablen in `apps/web/src/styles/globals.css` definieren und in `tailwind.config.ts` als Theme spiegeln. Werte gemäß UX-Spec §Visual Design Foundation:
|
||||
- `--color-bg: #ffffff`, `--color-surface: #fafafa`, `--color-surface-elevated: #ffffff`
|
||||
- `--color-border: #e5e5e5`, `--color-border-strong: #d4d4d4`
|
||||
- `--color-ink: #0a0a0a`, `--color-ink-muted: #525252`, `--color-ink-faint: #a3a3a3`
|
||||
- `--color-brand: #0a0a0a`, `--color-brand-fg: #ffffff`
|
||||
- Ampel: green `#15803d`/`#dcfce7`, amber `#a16207`/`#fef3c7`, red `#b91c1c`/`#fee2e2`
|
||||
- Trend: up `#15803d`, flat `#525252`, down `#b91c1c`
|
||||
- **UX-DR2** Typografie: Inter (UI, variable) + JetBrains Mono (Werte) selbst-gehostet via `@fontsource/inter` und `@fontsource/jetbrains-mono`. 7-Stufen-Type-Scale aus UX-Spec §Typography.
|
||||
- **UX-DR3** Spacing-Skala, Radii und Schatten gemäß UX-Spec §Spacing & Layout — als Tailwind-Theme-Erweiterung.
|
||||
- **UX-DR4** Container-Max-Width 1.280 px, 12-Spalten-Grid mit 24 px Gap; Puls auf Desktop 3-Spalten.
|
||||
|
||||
**Custom-Component-Library (`apps/web/src/components/ui/`)**
|
||||
|
||||
- **UX-DR5** `Button` mit 3 Varianten (primary/secondary/ghost) + Destructive + Icon-Only, 3 Größen (sm/md/lg).
|
||||
- **UX-DR6** `Card` mit Compound-API (`Card.Root`, `Card.Header`, `Card.Body`, `Card.Footer`).
|
||||
- **UX-DR7** `Input`, `Textarea` (mit Zeichen-Counter), `Select` (via Radix), `Checkbox` (via Radix mit animiertem Häkchen).
|
||||
- **UX-DR8** `AmpelButton` (3-Wege-Toggle Grün/Gelb/Rot, große Klick-Targets), `TrendButton` (3-Wege ↗︎/→/↘︎).
|
||||
- **UX-DR9** `Dialog` (via Radix mit Brand-Header und `radius-xl`), `Tooltip` (via Radix, schwarzer Bubble), `Toast` (via Sonner, Bottom-Right, schwarzer Pill).
|
||||
- **UX-DR10** `SkeletonCard` — graue Card-Skelette identischer Geometrie wie echte Cards.
|
||||
- **UX-DR11** `AmpelBadge` — Status-Pill mit Farbpunkt + Label + optionalem Trend-Pfeil.
|
||||
- **UX-DR12** `PflichtMinimumIndicator` — sechs kleine Quadrate horizontal, gefüllt/leer.
|
||||
- **UX-DR13** `KeyCaret` — SVG-Komponente für das Brand-`^`-Element, einbindbar in Header/Logo/Toast.
|
||||
|
||||
**Layout & Navigation**
|
||||
|
||||
- **UX-DR14** TopNav fest mit `cockpit ^` Logo links, Tabs (Puls / Entwicklung / Vertrieb / Marketing / Mein heute / Archiv), rechts Streak-Badge + Avatar. Aktiver Tab mit 2 px schwarzem Underline.
|
||||
- **UX-DR15** Breadcrumbs in Detail-Views (`Puls › Entwicklung › Projekt X`), klickbar pro Segment.
|
||||
- **UX-DR16** Tastatur-Shortcuts: `g p`, `g h`, `g e`, `g v`, `g m`, `g a`, `n` (Neuer Blocker), `?` (Cheatsheet), `Esc` (Modal schließen).
|
||||
|
||||
**Voice & Tone (Mikrocopy)**
|
||||
|
||||
- **UX-DR17** Zentrale Mikrocopy-Datei `apps/web/src/lib/voice.ts` mit allen UI-Strings. Beispiele: „Stark.", „Wie läuft es?", „Du hast heute noch nicht gepingt.", „Reflektiert.", „Blocker offen.". Pflicht-Anwendung in allen Komponenten.
|
||||
|
||||
**User-Journey-Flows**
|
||||
|
||||
- **UX-DR18** UJ-1 Tagesrhythmus: `/mein-heute` (Morgen-Einstempeln + ToDo-Editor) und `/reflexion` (Abend-Reflexion mit Tagesplan-Echo).
|
||||
- **UX-DR19** UJ-2 Jourfixe: Puls-Layout muss alle 3 Bereiche in einem Scroll abdeckbar machen.
|
||||
- **UX-DR20** UJ-5 Projekt anlegen: Modal mit Pflicht-Feldern (Name, P1, Projektart, Arbeitsmodus, Steckbrief-URL, Plane-URL).
|
||||
|
||||
**Responsive & Accessibility**
|
||||
|
||||
- **UX-DR21** Drei Sicht-Ebenen: Desktop (≥ 1280 px, 3-Spalten Puls), Tablet (≥ 768 px, 2-Spalten), Mobile (< 768 px, Single-Column + Bottom-Tab-Bar, lesbar).
|
||||
- **UX-DR22** WCAG 2.1 AA durchgängig. Keyboard-First (Tab, Enter, Space, Esc). Sichtbarer Fokus-Ring (`outline: 2px solid var(--color-ink)`, 2 px Offset).
|
||||
- **UX-DR23** Skip-Link „Zum Hauptinhalt springen" oben. Saubere Heading-Hierarchie. Live-Regionen für Toasts.
|
||||
- **UX-DR24** `prefers-reduced-motion` → Fade-Animationen auf 0 ms.
|
||||
- **UX-DR25** axe-core in Vitest pro Komponente; `@axe-core/playwright` für E2E. ESlint-`jsx-a11y` mit `recommended` aktiviert.
|
||||
|
||||
**Interaktions-Patterns**
|
||||
|
||||
- **UX-DR26** Optimistic Updates bei Checkbox-Abhaken und Ampel-Setzen (Rollback bei API-Fehler).
|
||||
- **UX-DR27** Skeleton-Cards beim Initial-Load — nie leerer Bildschirm, nie Spinner alleine.
|
||||
- **UX-DR28** Empty-States als Voice-konforme Tropfen-Information (z. B. „Hier erscheinen die Updates des Tages, sobald jemand reflektiert hat.").
|
||||
- **UX-DR29** Reflexions-Erinnerung im Header ab 17:00 (dezenter Pill, klickbar).
|
||||
- **UX-DR30** Vortrags-Reveal: Fade-In ≤ 200 ms beim ersten Laden am Werktag X+1.
|
||||
|
||||
### FR Coverage Map
|
||||
|
||||
| FR | Epic | Kurztitel |
|
||||
|---|---|---|
|
||||
| FR-1, FR-1a, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7 | Epic 3 | Puls & Übersicht |
|
||||
| FR-8, FR-9, FR-10, FR-11 | Epic 4 | Bereich Entwicklung |
|
||||
| FR-12, FR-13, FR-14, FR-15, FR-16 | Epic 4 | Bereich Vertrieb |
|
||||
| FR-17, FR-18, FR-19, FR-20 | Epic 4 | Bereich Marketing |
|
||||
| FR-21 – FR-33 | Epic 2 | Tagesrhythmus |
|
||||
| FR-34, FR-35, FR-36, FR-37 | Epic 2 + Epic 3 | Streak + Anwesenheit |
|
||||
| FR-38, FR-39, FR-40 | Epic 5 | Pflicht-Minimum |
|
||||
| FR-41, FR-42, FR-43, FR-44 | Epic 5 | Blocker |
|
||||
| FR-45, FR-46, FR-47 | Epic 6 | Projekt-Abschluss |
|
||||
| FR-48 | Epic 1 | Navigation (Top-Nav) |
|
||||
| FR-49 | Phase 2 | Volltextsuche |
|
||||
|
||||
**NFR-Abdeckung:** NFR-5/7/8/9 ⇒ Epic 1 · NFR-1/2 ⇒ über alle Epics validiert · NFR-3/4/14/15/16 ⇒ Epic 6 · NFR-10/11/12/13 ⇒ Epic 1 + Epic 2 (Voice-Anker etabliert)
|
||||
|
||||
## Epic List
|
||||
|
||||
> 6 Epics, jedes liefert ein eigenständig nutzbares Stück. Reihenfolge folgt der in `architecture.md` § "Decision Impact Analysis → Implementation Sequence" festgelegten Story-Sequenz.
|
||||
|
||||
### Epic 1: Fundament — Login, Shell, Brand
|
||||
|
||||
**Ziel:** Eine Person kann sich via Authentik einloggen und landet in einer leeren, brand-konformen Cockpit-Shell mit funktionierender Navigation. Es passiert noch nichts Fachliches — aber das Fundament steht.
|
||||
|
||||
**Was nach Epic 1 nutzbar ist:**
|
||||
- Login/Logout funktioniert
|
||||
- TopNav steht (Puls-Tab, Profil, Streak-Badge-Slot leer)
|
||||
- Brand-Theme aktiv (Farben, Typografie, KeyCaret-Logo)
|
||||
- Healthcheck + Docker-Compose laufen lokal
|
||||
|
||||
**FRs:** FR-48
|
||||
**NFRs:** NFR-5, NFR-7, NFR-8, NFR-9 (vorbereitet), NFR-11, NFR-12, NFR-13, NFR-14
|
||||
**AR:** AR-1, AR-2 (Schema-Skeleton User+Session+AuditEvent), AR-3, AR-4 (Procedure-Typen-Skeleton), AR-5 (Audit-Wrapper), AR-10, AR-15, AR-13 (pino), AR-14 (Healthcheck)
|
||||
**UX-DR:** UX-DR1, UX-DR2, UX-DR3, UX-DR4, UX-DR5, UX-DR6, UX-DR7, UX-DR13, UX-DR14, UX-DR15, UX-DR16, UX-DR17, UX-DR22, UX-DR23, UX-DR24, UX-DR25
|
||||
|
||||
### Epic 2: Tagesrhythmus — Einstempeln, ToDos, Reflexion
|
||||
|
||||
**Ziel:** Eine Person kann morgens einstempeln + ToDos schreiben, abends reflektieren mit Ampel/Trend, und am nächsten Werktag wird der konsolidierte Eintrag für Kollegen sichtbar. Das Herz des Cockpits.
|
||||
|
||||
**Was nach Epic 2 nutzbar ist:**
|
||||
- Person kann den vollen Tagesrhythmus durchlaufen (UJ-1)
|
||||
- Streak und Tages-Status erscheinen im Profil und in der „Heute fehlt"-Liste (basal — volle Puls-Visualisierung kommt in Epic 3)
|
||||
- Cron-Job publiziert Vortags-Einträge
|
||||
|
||||
**FRs:** FR-21 – FR-33, FR-34, FR-36, FR-37
|
||||
**AR:** AR-2 (Schema DayEntry, Todo, AbsenceDay), AR-4 (`dayEntryVisibleProcedure`), AR-5, AR-6 (publish-day-entries cron + recompute-streaks cron), AR-7
|
||||
**UX-DR:** UX-DR8 (AmpelButton, TrendButton), UX-DR9 (Dialog für AbwesendDialog), UX-DR10 (SkeletonCard), UX-DR18 (`/mein-heute` + `/reflexion`), UX-DR26 (optimistic), UX-DR27 (Skeleton), UX-DR28 (Empty-State), UX-DR29 (17:00-Hinweis), UX-DR30 (Vortrags-Reveal Fade-In)
|
||||
|
||||
### Epic 3: Puls & Bereichs-Übersicht
|
||||
|
||||
**Ziel:** Eine Person öffnet das Cockpit und sieht in unter 60 Sekunden den Unternehmens-Puls: alle drei Bereiche mit aggregiertem Ampelstatus, Heute-Quote, bereichsübergreifende Blocker, Jourfixe-Hinweis. Vortrags-Einträge im Feed.
|
||||
|
||||
**Was nach Epic 3 nutzbar ist:**
|
||||
- Puls-Seite ist die Heimat (UJ-2, UJ-3)
|
||||
- „Heute fehlt"-Liste klickbar mit Personen
|
||||
- Bereichs-Strip mit Ampel-Aggregation
|
||||
- Feed konsolidierter Vortrags-Einträge
|
||||
|
||||
**FRs:** FR-1, FR-1a, FR-2, FR-3, FR-4, FR-5 (Schema-Layout, KPIs werden mit Echtdaten in Epic 4 gefüllt), FR-6, FR-7 (Pflicht-Minimum-Slot, gefüllt in Epic 5), FR-35
|
||||
**AR:** AR-8 (Ampel-Aggregation), AR-7 (Werktage)
|
||||
**UX-DR:** UX-DR11 (AmpelBadge), UX-DR19 (Puls-Layout 3-Spalten)
|
||||
|
||||
### Epic 4: Projekte, Deals, Kampagnen — Bereichs-Details
|
||||
|
||||
**Ziel:** Eine Person kann Projekte (Entwicklung), Deals (Vertrieb) und Kampagnen (Marketing) anlegen und pflegen. Bereichs-Detail-Views zeigen Listen, Kanban, Lead-Funnel, Crosslinks. Reflexionen (aus Epic 2) setzen Projekt-Ampeln korrekt.
|
||||
|
||||
**Was nach Epic 4 nutzbar ist:**
|
||||
- Projekte/Deals/Kampagnen können angelegt, geändert und gelistet werden (UJ-5)
|
||||
- Sales-Kanban funktioniert (Drag-and-Drop, Stage-Wechsel)
|
||||
- Lead-Funnel + Crosslink-Banner zeigen Aggregate
|
||||
- KPIs im Puls-Bereichs-Strip (FR-5) zeigen Echtdaten
|
||||
|
||||
**FRs:** FR-8, FR-9, FR-10, FR-11, FR-12, FR-13, FR-14, FR-15, FR-16, FR-17, FR-18, FR-19, FR-20, FR-31
|
||||
**AR:** AR-2 (Schema Project, Milestone, Deal, GtmStep, Campaign, LeadHandover)
|
||||
**UX-DR:** UX-DR9 (Dialog für Projekt-Anlegen), UX-DR20 (Projekt-Anlege-Modal)
|
||||
|
||||
### Epic 5: Pflicht-Minimum & Blocker
|
||||
|
||||
**Ziel:** Die 6 Leitfaden-Pflicht-Punkte pro Projekt werden sichtbar gemacht. Blocker können erfasst, gelistet und (nur durch P1) aufgelöst werden.
|
||||
|
||||
**Was nach Epic 5 nutzbar ist:**
|
||||
- Pflicht-Minimum-Indikator in Projekt-Card und -Detail (FR-7 wird voll funktional auf dem Puls)
|
||||
- Blocker-Lebenszyklus komplett (Erfassen via Reflexion-Inline oder „+ Neuer Blocker"-Button, Anzeige im Puls/Bereich/Projekt, Auflösung)
|
||||
- Frühwarn-Mechanik aktiv
|
||||
|
||||
**FRs:** FR-38, FR-39, FR-40, FR-41, FR-42, FR-43, FR-44
|
||||
**AR:** AR-4 (`projectOwnerProcedure`), AR-6 (recompute-pflicht-minimum-5 cron), AR-9
|
||||
**UX-DR:** UX-DR12 (PflichtMinimumIndicator)
|
||||
|
||||
### Epic 6: Projekt-Abschluss, Archiv & Production
|
||||
|
||||
**Ziel:** Projekte können abgeschlossen werden (mit Lessons-Learned-Link), Archiv-Sicht existiert. Production-Pipeline ist scharf: CI/CD, tägliches Backup, Healthcheck-Monitoring, A11y-Tests im CI.
|
||||
|
||||
**Was nach Epic 6 nutzbar ist:**
|
||||
- Volle Projekt-Lebensspanne von Anlage bis Lessons-Learned
|
||||
- Cockpit ist deploybar und beobachtbar
|
||||
- Backup + Restore-Drill dokumentiert
|
||||
- A11y-Score ≥ 95 im CI gemessen
|
||||
|
||||
**FRs:** FR-45, FR-46, FR-47
|
||||
**NFRs:** NFR-1, NFR-2, NFR-3, NFR-4, NFR-10 (Responsive-Validierung), NFR-15, NFR-16
|
||||
**AR:** AR-6 (pg-dump-backup), AR-11 (Gitea Actions), AR-12 (Tests, Playwright), AR-14 (Healthcheck-Erweiterung), AR-16 (Backup + Restore-Drill)
|
||||
**UX-DR:** UX-DR21 (Responsive-Sichtbarkeitsprüfung), UX-DR25 (axe-core + jsx-a11y in CI)
|
||||
|
||||
---
|
||||
|
||||
**Phase-2-Backlog (nicht im MVP, nicht in Epics):**
|
||||
|
||||
- FR-49 Volltextsuche
|
||||
- Plane-/Outline-API-Sync
|
||||
- Mobile schreibbar
|
||||
- Reaktionen / Kommentare auf Updates
|
||||
- Element/Matrix/Mail-Reminders
|
||||
- Loki/Grafana, Sentry, Feiertags-Kalender, Dark Mode
|
||||
|
||||
---
|
||||
|
||||
## Epic 1: Fundament — Login, Shell, Brand
|
||||
|
||||
Eine Person kann sich via Authentik einloggen und landet in einer leeren, brand-konformen Cockpit-Shell mit funktionierender Navigation. Es passiert noch nichts Fachliches — aber das Fundament steht und alle weiteren Epics können darauf aufsetzen.
|
||||
|
||||
### Story 1.1: Repo-Bootstrap und Tooling
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **ein lauffähiges Monorepo mit Vite-Frontend, Fastify-Backend, Drizzle-DB-Package und Shared-Package**,
|
||||
sodass **alle Folge-Stories auf einem konsistenten Setup aufsetzen können**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ein leeres `cockpit/`-Repo,
|
||||
**When** `pnpm install` und `pnpm typecheck` ausgeführt werden,
|
||||
**Then** alle 4 Workspaces (`apps/web`, `apps/api`, `packages/db`, `packages/shared`) sind installiert und typecheckbar
|
||||
**And** `pnpm --filter web dev` startet Vite auf Port 5173
|
||||
**And** `pnpm --filter api dev` startet Fastify auf Port 3000
|
||||
**And** `CONVENTIONS.md` ist im Repo-Root vorhanden und spiegelt die Patterns aus `architecture.md` § Implementation Patterns
|
||||
**And** ESLint und Prettier sind konfiguriert und liefern keinen Fehler beim Start.
|
||||
|
||||
### Story 1.2: Postgres + Drizzle-Schema-Skeleton mit User, Session, AuditEvent
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **eine laufende Postgres-Instanz und das Drizzle-Schema für die drei Fundament-Entitäten**,
|
||||
sodass **Auth und Audit auf einer realen DB-Schicht funktionieren können**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Docker auf dem Dev-Host,
|
||||
**When** `docker compose -f docker-compose.dev.yml up postgres` läuft,
|
||||
**Then** Postgres 16 ist auf Port 5432 erreichbar
|
||||
**And** `packages/db/schema.ts` definiert die Tabellen `user`, `session`, `audit_event` mit UUID-v7-IDs, snake_case-Spalten und `timestamptz`-Zeitstempeln
|
||||
**And** `pnpm --filter db migrate:generate` erzeugt eine Migration `0000_init.sql`
|
||||
**And** `pnpm --filter db migrate` führt sie aus und die drei Tabellen existieren in der DB.
|
||||
|
||||
### Story 1.3: Authentik-OIDC-Login-Flow
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **mich via Authentik (SSO) einloggen und nach dem Login automatisch auf der Cockpit-Heimseite landen**,
|
||||
sodass **ich ohne separate Account-Anlage Zugang zum Cockpit bekomme**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich öffne `cockpit.tool-ctrl.de` ohne Session,
|
||||
**When** das Backend mich an `auth.tool-ctrl.de` weiterleitet und ich mich dort erfolgreich einlogge,
|
||||
**Then** werde ich auf `/puls` zurückgeleitet
|
||||
**And** ein HttpOnly + SameSite=Lax + Secure Cookie `session_id` ist gesetzt
|
||||
**And** in der DB existiert eine `session`-Zeile mit `expires_at` 24 h in der Zukunft
|
||||
**And** in der DB existiert eine `user`-Zeile mit `authentik_sub`, `name`, `email` und (sofern Group-Claim vorhanden) `default_area_id`
|
||||
**And** ein erneutes Aufrufen jeder geschützten Route innerhalb der Session-Gültigkeit funktioniert ohne erneuten Login
|
||||
**And** `POST /api/auth/logout` invalidiert die Session in der DB und löscht das Cookie.
|
||||
|
||||
### Story 1.4: tRPC-Bootstrap mit `protectedProcedure` und Audit-Wrapper
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **die tRPC-v11-Infrastruktur mit Context, `protectedProcedure` und `withAudit`-Wrapper laufen haben**,
|
||||
sodass **alle fachlichen Stories direkt typsicher Procedures bauen können**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Story 1.3 ist gemerged,
|
||||
**When** ein Client `trpc.user.me` ohne Session aufruft,
|
||||
**Then** liefert das Backend `TRPCError(UNAUTHORIZED)` mit deutscher Message
|
||||
**And** mit gültiger Session liefert es das `User`-Objekt aus der DB
|
||||
**And** `withAudit` wrapt jede Mutation und schreibt parallel in `audit_event` in derselben Transaktion (verifiziert via Test)
|
||||
**And** der Frontend-tRPC-Client in `apps/web/src/lib/trpc.ts` ist konfiguriert mit zentralem `onError`-Toast und nutzt die `AppRouter`-Typdefinition aus `packages/shared`.
|
||||
|
||||
### Story 1.5: Brand-Tokens, Tailwind-Theme und Voice-Datei
|
||||
|
||||
Als **UI-Entwickler**,
|
||||
ich möchte **die ToolCTRL-Brand-Tokens als CSS-Variablen und Tailwind-Theme-Erweiterung aktiv haben**,
|
||||
sodass **alle weiteren UI-Komponenten brand-konform stylebar sind**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Story 1.1 ist mergeable,
|
||||
**When** ich `apps/web/src/styles/globals.css` lade,
|
||||
**Then** sind alle Farb-Tokens, Spacing-, Radii- und Schatten-Variablen aus der UX-Spec definiert
|
||||
**And** `tailwind.config.ts` spiegelt diese Tokens als Theme-Erweiterung
|
||||
**And** `@fontsource/inter` und `@fontsource/jetbrains-mono` sind installiert und geladen
|
||||
**And** `apps/web/src/lib/voice.ts` existiert mit allen Mikrocopy-Strings aus UX-Spec § Voice & Tone als exportierte Konstanten
|
||||
**And** eine Test-Route `/dev/tokens` (nur in `import.meta.env.DEV`) zeigt eine Palette aller Tokens als visuelle Referenz.
|
||||
|
||||
### Story 1.6: TopNav-Shell mit Logo, Tabs, Profil-Slot und Keyboard-Shortcuts
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **eine sichtbare, navigierbare Cockpit-Shell mit Brand-Logo, Hauptnavigation und Profil-Bereich**,
|
||||
sodass **ich die App-Struktur sofort verstehe**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin eingeloggt,
|
||||
**When** ich `cockpit.tool-ctrl.de` öffne,
|
||||
**Then** sehe ich oben links `cockpit ^` (KeyCaret-SVG + Lockup) und in der Mitte die TopNav-Tabs `Puls`, `Entwicklung`, `Vertrieb`, `Marketing`, `Mein heute`, `Archiv`
|
||||
**And** rechts erscheint mein Avatar (Initialen) und ein Streak-Badge-Platzhalter
|
||||
**And** der aktive Tab zeigt einen 2 px dicken schwarzen Underline
|
||||
**And** Tastatur-Shortcuts `g p`, `g h`, `g e`, `g v`, `g m`, `g a` springen auf die jeweilige Route
|
||||
**And** `?` öffnet einen Cheatsheet-Dialog (alle Shortcuts gelistet)
|
||||
**And** WCAG 2.1 AA wird erfüllt (axe-core-Test grün), Fokus-Ring sichtbar auf jedem Tab.
|
||||
|
||||
### Story 1.7: Healthcheck, Docker-Compose und CI-Skeleton
|
||||
|
||||
Als **DevOps**,
|
||||
ich möchte **`docker compose up` startet Web + API + Postgres + Caddy lokal und der Healthcheck antwortet**,
|
||||
sodass **Deployments und CI auf einer reproduzierbaren Basis aufsetzen können**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** `docker-compose.yml` und `Caddyfile` existieren,
|
||||
**When** `docker compose up -d --build` läuft,
|
||||
**Then** sind alle vier Container `web`, `api`, `postgres`, `caddy` healthy
|
||||
**And** `GET /api/health` liefert `200 OK` mit JSON `{ "status": "ok", "db": "ok", "authentik": "ok" }`
|
||||
**And** `GET /` liefert die SPA-Index (statisch via Caddy)
|
||||
**And** ein minimales Gitea-Actions-Workflow `ci.yml` läuft Typecheck, Lint und Vitest auf jedem Push.
|
||||
|
||||
---
|
||||
|
||||
## Epic 2: Tagesrhythmus — Einstempeln, ToDos, Reflexion
|
||||
|
||||
Eine Person kann den vollen Tagesrhythmus durchlaufen: morgens einstempeln + ToDos schreiben, abends reflektieren, am nächsten Werktag wird der Eintrag konsolidiert. Das ist die Kern-Erfahrung des Cockpits.
|
||||
|
||||
### Story 2.1: DayEntry- und Todo-Schema, AbsenceDay, Werktag-Helper
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **die DB-Tabellen für Tageseinträge, ToDos und Abwesenheiten plus die Werktag-Logik im Backend**,
|
||||
sodass **die Tagesrhythmus-Procedures auf einer korrekten Datenbasis arbeiten**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Epic 1 ist abgeschlossen,
|
||||
**When** die Migration `0001_day_rhythm.sql` läuft,
|
||||
**Then** existieren in der DB die Tabellen `day_entry`, `todo`, `absence_day`, `blocker` gemäß Addendum § 1.2
|
||||
**And** `apps/api/src/lib/werktag.ts` bietet `isWerktag(date)`, `nextWerktag(date)`, `werktagsDistance(a, b)` mit Mo-Fr-Logik in `Europe/Berlin`
|
||||
**And** Vitest-Tests decken Grenzfälle ab (Wochenende, Tageswechsel um Mitternacht).
|
||||
|
||||
### Story 2.2: Einstempeln + ToDo-Eingabe auf „Mein heute"
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **mich morgens einstempeln und meine ToDos für heute eintippen**,
|
||||
sodass **mein Tag begonnen ist und ich für Kollegen als „eingestempelt" sichtbar bin**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich öffne `/mein-heute` als noch nicht eingestempelte Person,
|
||||
**When** ich auf „Einstempeln" klicke und 3 ToDos eintippe (Enter erzeugt neue Zeile, optionaler Bezug per Inline-Select),
|
||||
**Then** wird beim Klick auf „Eingestempelt" ein `day_entry` mit `status='begun'` und `checked_in_at` angelegt sowie 3 `todo`-Zeilen
|
||||
**And** ich werde auf `/puls` weitergeleitet
|
||||
**And** im „Heute fehlt"-Modul auf dem Puls erscheine ich nicht mehr (Kollegen sehen nur binäre Anwesenheit, nicht die ToDo-Inhalte — vgl. FR-24)
|
||||
**And** ein „Eingestempelt."-Toast erscheint 2 s in Bottom-Right
|
||||
**And** der Median-Speed-Test (manuelle Stoppuhr) zeigt ≤ 60 s für 3 ToDos (CM1a).
|
||||
|
||||
### Story 2.3: Tagsüber ToDos editieren, „Mein heute" als private Sicht
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **meine ToDos im Verlauf des Tages ergänzen oder ändern können**,
|
||||
sodass **mein Tagesplan realitätsnah bleibt — und kein Kollege das mitliest, bevor ich reflektiert habe**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin eingestempelt mit 3 ToDos,
|
||||
**When** ich `/mein-heute` erneut öffne,
|
||||
**Then** sehe ich meine ToDos editierbar (hinzufügen, ändern, löschen) bis zur Reflexion
|
||||
**And** Edits werden gespeichert via tRPC-Mutation `todo.update`/`todo.create`/`todo.delete`
|
||||
**And** ein anderer User, der dieselbe Person als „eingestempelt" sieht, kann **keine** ToDo-Inhalte abrufen (tRPC `dayEntry.todosForUser` liefert für andere User vor `published_at` nur `count`)
|
||||
**And** die Projekt-P1 darf die ToDos sehen, sofern ein ToDo Bezug auf ihr/sein Projekt hat — verifiziert durch tRPC-Test mit zwei verschiedenen User-Sessions.
|
||||
|
||||
### Story 2.4: Abend-Reflexion mit Tagesplan-Echo, Ampel + Trend
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **abends meine ToDos durchgehen, abhaken oder verwerfen, einen Freitext schreiben und Ampel/Trend setzen**,
|
||||
sodass **mein Tag offiziell als „voll" abgeschlossen gilt und der Status meines Projekts aktuell ist**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin eingestempelt und öffne `/reflexion`,
|
||||
**When** ich meine ToDos sehe (Tagesplan-Echo mit 200 ms Fade-In) und einige abhake, einige verwerfe (Inline-Grund-Feld) und einen Freitext mit ≤ 1000 Zeichen schreibe,
|
||||
**Then** kann ich Ampel (🟢/🟡/🔴 als drei große Buttons) und Trend (↗︎/→/↘︎) setzen
|
||||
**And** Trend ist Pflichtfeld, sofern mindestens ein ToDo Bezug=Projekt hat — Submit ohne Trend zeigt deutsche Inline-Fehlermeldung
|
||||
**And** Submit setzt `day_entry.status='full'`, `reflected_at`, schreibt `reflection_text`, `ampel`, `trend`
|
||||
**And** die Ampel + der Trend des verlinkten Projekts werden in derselben DB-Transaktion aktualisiert (`status_cache`, `trend_cache`)
|
||||
**And** ein „Reflektiert."-Toast erscheint, ich werde auf `/puls` redirected
|
||||
**And** Reflexion ist 30 min lang editierbar — danach erscheint sie unverändert.
|
||||
|
||||
### Story 2.5: Blocker inline in der Reflexion erfassen
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **bei der Reflexion einen Blocker mit einem Toggle eintragen**,
|
||||
sodass **das Team meine Hindernisse am nächsten Werktagsmorgen sieht**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin in der Reflexion,
|
||||
**When** ich den „Blocker?"-Toggle aktiviere und Titel + Beschreibung eintippe,
|
||||
**Then** wird beim Submit zusätzlich ein `blocker`-Datensatz erzeugt
|
||||
**And** der Blocker ist mit `opened_by = mein User` und (sofern ToDo-Bezug=Projekt) `project_id` verknüpft
|
||||
**And** der `day_entry.blocker_id` zeigt auf den neuen Blocker
|
||||
**And** der Blocker erscheint **erst nach Publikation am nächsten Werktag** im bereichsübergreifenden Blocker-Modul des Puls (Verifizierung mit zweiter Session).
|
||||
|
||||
### Story 2.6: Cron „publish-day-entries" + Streak-Berechnung
|
||||
|
||||
Als **System**,
|
||||
möchte ich **nachts um 00:05 Europe/Berlin die DayEntries des Vortags publizieren und Streaks neu berechnen**,
|
||||
sodass **am Morgen X+1 die konsolidierten Vortrags-Einträge sichtbar werden und Streaks korrekt sind**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** mehrere User haben am Vortag eingestempelt + reflektiert,
|
||||
**When** der System-Cron `POST /internal/cron/publish-day-entries` mit Bearer-Token aufruft,
|
||||
**Then** werden alle `day_entry`-Zeilen des Vortags mit `published_at = heute 00:00` versehen
|
||||
**And** die nachfolgende tRPC-Procedure `dayEntry.feedForArea` liefert diese Einträge inklusive ToDos + Reflexion für alle Kollegen
|
||||
**And** ein zweiter Cron-Aufruf `recompute-streaks` setzt `user.current_streak` korrekt (vollständige + halbe + abwesende Tage gemäß FR-33/34)
|
||||
**And** Cron-Aufrufe ohne korrekten Bearer-Token liefern `401`.
|
||||
|
||||
### Story 2.7: Streak-Badge, Abwesenheit, „Halb"-Status
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **meine aktuelle Streak in der TopNav sehen und mich für Tage als abwesend markieren können**,
|
||||
sodass **Urlaubstage nicht meine Streak brechen**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin eingeloggt,
|
||||
**When** ich die TopNav betrachte,
|
||||
**Then** sehe ich rechts neben meinem Avatar einen Pill „N ^" (N = aktuelle Streak, KeyCaret als Suffix)
|
||||
**And** ich kann via Profil-Modal einen Zeitraum als „abwesend" (Urlaub/Krank/Sonstiges) markieren
|
||||
**And** Abwesenheits-Tage werden als `absence_day` gespeichert und im Streak-Cron als „nicht gegen die Serie zählend" behandelt
|
||||
**And** wenn ich morgens nur einstempele aber nicht reflektiere, zeigt `dayEntry.todayStatus` für mich `status='begun'` und mein Tages-Status-Indikator wird gelb („halb")
|
||||
**And** der nächste Streak-Cron würde diesen Tag nicht für die Serie zählen (verifiziert via Unit-Test in `werktag.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Epic 3: Puls & Bereichs-Übersicht
|
||||
|
||||
Eine Person öffnet das Cockpit und sieht in unter 60 Sekunden alles Wesentliche: drei Bereiche mit Ampel-Aggregat, Heute-Quote, Blocker, Jourfixe-Hinweis, Vortrags-Feed.
|
||||
|
||||
### Story 3.1: Area-Schema, Aggregations-Algorithmus, Puls-Procedure
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **die `area`-Tabelle und die `apps/api/src/lib/ampel.ts`-Aggregations-Logik plus eine tRPC-Procedure `puls.overview`**,
|
||||
sodass **die Puls-Frontend-Seite alle Aggregat-Daten in einem Roundtrip bekommt**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** die DB enthält drei `area`-Zeilen (dev, sales, marketing) und (zunächst leere) Projekt-Tabelle,
|
||||
**When** `trpc.puls.overview` ohne Filter aufgerufen wird,
|
||||
**Then** liefert es ein Objekt mit `areas: [{ id, name, ampel, ampel_count, missing_today_count }]`, `blockers: [...]`, `heuteQuote: { gepingt, gesamt, fehlende: [...] }`, `jourfixe: { nextDate, label }`
|
||||
**And** die Ampel-Aggregation pro Bereich folgt FR-1a-Algorithmus (verifiziert via Vitest-Test mit Fixtures aus mehreren Status-Kombinationen)
|
||||
**And** die Procedure liefert in < 200 ms bei 6-15 Usern und 90 Tagen Historie (Performance-Test mit `vitest --bench`).
|
||||
|
||||
### Story 3.2: Puls-Seite Layout mit Bereichs-Strip und Heute-Quote
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **auf `/puls` in einem Blick die drei Bereiche mit Ampelstatus und die Heute-Quote sehen**,
|
||||
sodass **ich morgens innerhalb 60 s einen Überblick habe**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich öffne `/puls`,
|
||||
**When** die Seite geladen hat,
|
||||
**Then** sehe ich oben einen Hero mit „Stand: Di, 25. Mai 2026"
|
||||
**And** unter dem Hero ein 3-Spalten-Grid mit den drei Bereichen, jeweils mit AmpelBadge, Bereichsname, Anzahl fehlender Updates heute, und einer Klick-Aktion zur Bereichs-Detail-View
|
||||
**And** rechts (oder darunter auf Tablet) ein „Heute fehlt"-Modul mit `X/Y eingestempelt`-Stat und Klick auf Detail-Liste der fehlenden Personen (mit Avatar + Name)
|
||||
**And** wenn ich noch nicht eingestempelt bin, erscheint prominent oberhalb des Folds der CTA „Du hast heute noch nicht gepingt." mit „Einstempeln"-Primary-Button → führt zu `/mein-heute`
|
||||
**And** Skeleton-Cards beim Initial-Load, Fade-In ≤ 200 ms beim Datenanstieg.
|
||||
|
||||
### Story 3.3: Blocker-Modul, Jourfixe-Hinweis, KPI-Slots
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **auf `/puls` die offenen Blocker, den nächsten Jourfixe und KPI-Slots pro Bereich sehen**,
|
||||
sodass **ich den ganzen Unternehmens-Puls auf einer Seite verstehe**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich öffne `/puls`,
|
||||
**When** die Seite vollständig geladen ist,
|
||||
**Then** sehe ich unter dem Bereichs-Strip ein Modul „Bereichsübergreifend offen" mit allen offenen Blockern sortiert nach Alter
|
||||
**And** ein Modul „Jourfixe" zeigt den nächsten Termin (Default „Di, 11:00")
|
||||
**And** pro Bereich erscheint eine KPI-Kachel mit Slots aus FR-5 (Anzahl Projekte, Status-Verteilung etc.) — Werte sind in Epic 3 ggf. noch 0 (echte Daten kommen in Epic 4)
|
||||
**And** das Modul „Pflicht-Minimum" ist als Slot vorhanden, zeigt aber bis Epic 5 nur „Keine Projekte mit unerfülltem Pflicht-Minimum" als Empty-State.
|
||||
|
||||
### Story 3.4: Vortrags-Feed im Puls + „Heute fehlt"-Detail
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **am Morgen X+1 die konsolidierten Vortrags-Einträge meiner Kollegen im Feed sehen**,
|
||||
sodass **ich weiß, was gestern lief**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** am Tag X haben N Personen reflektiert und der Publikations-Cron lief um 00:05,
|
||||
**When** ich am Tag X+1 morgens `/puls` öffne,
|
||||
**Then** sehe ich unten einen „Gestern im Team"-Feed mit konsolidierten DayEntries (Avatar + Name + Bereich + Ampel + Trend + Reflexionstext + abgehakte/verworfene ToDos)
|
||||
**And** der Feed sortiert neueste oben, paginiert 50/Seite mit „Mehr laden"
|
||||
**And** Klick auf einen Eintrag öffnet die Detail-Sicht (read-only)
|
||||
**And** das „Heute fehlt"-Modul klickt jetzt auf eine vollständige Liste der heute noch nicht eingestempelten Personen
|
||||
**And** Performance-Budget < 1,5 s gesamtladezeit auf `/puls` mit 90 Tagen Historie (Lighthouse-Test).
|
||||
|
||||
---
|
||||
|
||||
## Epic 4: Projekte, Deals, Kampagnen — Bereichs-Details
|
||||
|
||||
Eine Person kann Projekte, Deals und Kampagnen anlegen und in den jeweiligen Bereichs-Detail-Views arbeiten. Die KPI-Slots im Puls werden mit Echtdaten gefüllt.
|
||||
|
||||
### Story 4.1: Project-, Milestone- und Verlinkungs-Schema, Anlage-Modal
|
||||
|
||||
Als **Projektverantwortliche/r (P1)**,
|
||||
ich möchte **ein Projekt im Cockpit anlegen können mit allen Cockpit-Sicht-Feldern und Pflicht-Verlinkungen**,
|
||||
sodass **das Projekt im Puls und in der Bereichs-Detail-View erscheint**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin eingeloggt und in der Bereichs-Detail-View Entwicklung,
|
||||
**When** ich auf „+ Neues Projekt" klicke,
|
||||
**Then** öffnet sich ein Dialog mit den Pflichtfeldern Name, Verantwortliche/r (P1, User-Auswahl), Projektart (Kundenprojekt/Internes Projekt), Arbeitsmodus (Cycle/Kanban), Outline-Steckbrief-URL, Plane-Board-URL
|
||||
**And** optional DoD-Text, Risikoliste-URL, Start, Deadline und 0..N grobe Meilensteine inline
|
||||
**And** beim Submit werden `project`- und `milestone`-Zeilen geschrieben (Migration `0002_projects.sql`), audit-getracked
|
||||
**And** das neue Projekt erscheint sofort im Bereich Entwicklung (FR-8/9) und im Puls-KPI-Slot.
|
||||
|
||||
### Story 4.2: Bereichs-Detail Entwicklung mit Projekt-Karten und Detail-View
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **die Liste aller Entwicklungs-Projekte in zwei Gruppen (Kundenprojekt/Internes Projekt) sehen und in die Detail-Sicht klicken**,
|
||||
sodass **ich Status, Meilensteine und letzte Updates pro Projekt erfasse**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** mindestens 2 Projekte (eines Kundenprojekt, eines internes) existieren,
|
||||
**When** ich `/bereich/entwicklung` öffne,
|
||||
**Then** sehe ich zwei Sektionen „Kundenprojekte" und „Interne Projekte" mit Projekt-Cards (Name, P1-Avatar, AmpelBadge + Trend, Deadline-Progress-Bar, # offene Meilensteine, Pflicht-Minimum-Score Slot)
|
||||
**And** Klick auf Projekt-Card öffnet `/projekt/:id` mit der Detail-Sicht: Kopfzeile (Name, P1, Projektart, Arbeitsmodus, Ampel + Trend), Verlinkungen (Outline-Steckbrief, Plane-Board, optional Risikoliste), optional DoD-Text, Meilenstein-Checkliste, Update-Feed dieses Projekts
|
||||
**And** Meilensteine sind inline editierbar (Status: offen/erledigt/überzogen, Fälligkeit ändern) und werden in der Cockpit-DB persistiert.
|
||||
|
||||
### Story 4.3: Deal-Schema, Sales-Kanban und Pipeline-KPIs
|
||||
|
||||
Als **Vertrieb**,
|
||||
ich möchte **meine Deals als Kanban in 5 Stages sehen und per Drag-and-Drop verschieben**,
|
||||
sodass **die Pipeline immer aktuell ist**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Migration `0003_sales.sql` ist gelaufen und mind. 3 Deals existieren,
|
||||
**When** ich `/bereich/vertrieb` öffne,
|
||||
**Then** sehe ich oben das KPI-Header-Modul mit gewichtetem Pipeline-Wert (Σ value × probability), aktiven Deals-Count, gewonnenen Deals des laufenden Quartals (# + EUR formatiert als `12.500,00 €`), ⌀ Stage-Verweildauer
|
||||
**And** darunter ein 5-Spalten-Kanban (Lead → Erstgespräch → Demo → Angebot → Abschluss) mit Deal-Karten
|
||||
**And** Drag-and-Drop einer Karte auf eine andere Spalte ändert `deal.stage` und setzt `entered_stage_at = now()` (optimistic UI + Rollback bei Fehler)
|
||||
**And** unten ein gefilterter Vertrieb-Update-Feed (Bezug=Deal oder Vertrieb-allgemein).
|
||||
|
||||
### Story 4.4: GTM-Schritte und Vertrieb-Feed
|
||||
|
||||
Als **Vertrieb**,
|
||||
ich möchte **pro Produkt eine sortierbare GTM-Schritt-Liste pflegen**,
|
||||
sodass **die Markteinführung jedes Produkts strukturiert ist**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** im Vertriebs-Detail existiert ein Bereich „GTM-Schritte pro Produkt",
|
||||
**When** ich ein neues Produkt-Tab hinzufüge und Schritte (Titel + Verantwortlicher + Status) einpflege,
|
||||
**Then** wird `gtm_step` in der DB persistiert mit `order`-Feld für die Reihenfolge
|
||||
**And** Schritte sind per Drag-and-Drop oder Up/Down-Tastatur-Buttons umsortierbar (`order` wird neu vergeben in einer Transaktion)
|
||||
**And** der Status (offen/in Arbeit/erledigt) ist inline änderbar.
|
||||
|
||||
### Story 4.5: Campaign-Schema, Lead-Funnel und Crosslink-Banner
|
||||
|
||||
Als **Marketing**,
|
||||
ich möchte **den Lead-Funnel mit Conversion-Prozenten und die Kampagnen-Liste sehen, plus einen Crosslink-Banner zur Vertriebs-Übergabe**,
|
||||
sodass **ich verstehe, wie viele Leads in echte Deals umgewandelt werden**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Migration `0004_marketing.sql` ist gelaufen und `lead_handover` enthält Daten,
|
||||
**When** ich `/bereich/marketing` öffne,
|
||||
**Then** sehe ich oben einen horizontalen 5-Stufen-Lead-Funnel (Reichweite → Website → Leads → MQL → Übergabe) mit Conversion-% zwischen den Stufen
|
||||
**And** darunter eine Liste aktiver Kampagnen (Aktiv/Pausiert/Beendet, Reichweite, Verantwortlicher)
|
||||
**And** ein Crosslink-Banner zeigt „Marketing hat X Leads übergeben in [letzten 30 Tagen], Vertrieb hat daraus Y Deals erzeugt (Z € im Quartal)"
|
||||
**And** derselbe Banner spiegelt sich umgekehrt im Vertrieb-Detail (FR-19)
|
||||
**And** unten ein gefilterter Marketing-Update-Feed.
|
||||
|
||||
---
|
||||
|
||||
## Epic 5: Pflicht-Minimum & Blocker
|
||||
|
||||
Die 6 Leitfaden-Pflicht-Punkte pro Projekt werden sichtbar gemacht. Blocker-Lebenszyklus ist komplett — inkl. der Regel „nur P1 darf auflösen".
|
||||
|
||||
### Story 5.1: Pflicht-Minimum-State, Lazy-Berechnung und Cron-Punkt-5
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **die Pflicht-Minimum-Logik im Backend mit der korrekten Punkt-5-Aktualisierung**,
|
||||
sodass **der Status pro Projekt stets korrekt aus den Daten abgeleitet wird**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Migration `0005_pflicht_minimum.sql` ergänzt `project.pflicht_minimum_state` (JSONB) und `project.steckbrief_freigegeben` (bool),
|
||||
**When** `apps/api/src/lib/pflicht-minimum.ts` `computePflichtMinimum(projectId)` aufgerufen wird,
|
||||
**Then** liefert es ein Objekt mit 6 Booleans gemäß FR-38 Definition (Punkte 1-4 manuell aus DB-Feldern, Punkt 5 berechnet aus letztem Reflexions-Zeitstempel)
|
||||
**And** der Cron-Endpoint `POST /internal/cron/recompute-pflicht-minimum-5` setzt Punkt 5 auf `false` für alle Projekte mit letzter Reflexion > 7 Werktage zurück
|
||||
**And** der Aggregations-Algorithmus aus FR-1a wird ergänzt: Pflicht-Minimum-Verstöße heben Ampel mindestens auf Gelb (Vitest-Test mit Fixtures).
|
||||
|
||||
### Story 5.2: Pflicht-Minimum-Indikator und -Panel in der UI
|
||||
|
||||
Als **P1 / P4**,
|
||||
ich möchte **pro Projekt-Card und Projekt-Detail die 6 Pflicht-Punkte als visuelle Indikatoren sehen**,
|
||||
sodass **ich sofort sehe, was bei diesem Projekt fehlt**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** Stories 5.1 ist abgeschlossen und ein Projekt hat 4/6 Punkte erfüllt,
|
||||
**When** ich `/bereich/entwicklung` öffne,
|
||||
**Then** zeigt jede Projekt-Card unten rechts den `PflichtMinimumIndicator` mit 6 horizontalen Quadraten (gefüllt/leer) und Score „4/6"
|
||||
**And** auf `/projekt/:id` erscheint das `PflichtMinimumPanel` mit allen 6 Punkten als Checkliste, jeweils mit Status, kurzer Beschreibung und (für Punkte 1-4) Quick-Action-Button („Steckbrief verlinken", „P1 setzen", „Plane-Board verlinken", „Meilenstein anlegen")
|
||||
**And** Punkte 5 und 6 sind read-only mit Erklärungs-Tooltip
|
||||
**And** Projekte mit ≥ 1 unerfülltem Punkt erscheinen auf `/puls` im Modul „Pflicht-Minimum" (vorher leerer Slot wird gefüllt).
|
||||
|
||||
### Story 5.3: Blocker-Lebenszyklus — Erfassen, Anzeige, P1-Auflösung
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **Blocker außerhalb der Reflexion erfassen können und P1 kann sie auflösen**,
|
||||
sodass **das Team auf bereichsübergreifende Hindernisse reagieren kann**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** der „+ Neuer Blocker"-Button in der TopNav (Shortcut `n`),
|
||||
**When** ich auf ihn klicke und Titel + Beschreibung + Verantwortlichen + Bereich(e) + optionale Projekt-Verknüpfung eintrage,
|
||||
**Then** wird `blocker` in der DB persistiert mit `opened_by = ich`
|
||||
**And** der Blocker erscheint auf `/puls` im bereichsübergreifenden Modul, im jeweiligen Bereichs-Detail und (sofern projektgebunden) im Projekt-Detail
|
||||
**And** auf der Blocker-Card erscheint ein „Auflösen"-Button **nur sichtbar, wenn ich P1 des bezogenen Projekts bin** (oder bei projektlosen Blockern Ersteller oder Verantwortlicher)
|
||||
**And** ein nicht-P1-User, der versucht via direktem tRPC-Call aufzulösen, bekommt `TRPCError(FORBIDDEN)` mit deutscher Message
|
||||
**And** beim Auflösen werden `resolved_by`, `resolved_at` und `resolution_text` persistiert; Blocker verschwindet aus den offenen Listen.
|
||||
|
||||
### Story 5.4: Blocker im Projekt-Detail und Audit der Auflösung
|
||||
|
||||
Als **P1 / P4**,
|
||||
ich möchte **die Blocker-Historie pro Projekt einsehen können**,
|
||||
sodass **ich nachvollziehe, was und wann gelöst wurde**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ein Projekt hat mehrere Blocker (einige offen, einige gelöst),
|
||||
**When** ich `/projekt/:id` öffne,
|
||||
**Then** sehe ich einen Tab oder Bereich „Blocker" mit zwei Listen: offene Blocker oben (mit „Auflösen"-Button für P1), gelöste Blocker darunter (mit Auflöser-Name + Datum + Kommentar)
|
||||
**And** jede Blocker-Mutation (erstellen, lösen) hat einen entsprechenden `audit_event`-Eintrag (verifiziert per DB-Query in Test).
|
||||
|
||||
---
|
||||
|
||||
## Epic 6: Projekt-Abschluss, Archiv & Production
|
||||
|
||||
Projekte können abgeschlossen werden inkl. Lessons-Learned-Pflicht. Archiv-Sicht existiert. Production-Pipeline ist scharf: CI/CD, Backup, A11y-Tests.
|
||||
|
||||
### Story 6.1: Projekt-Abschluss-Workflow und Lessons-Learned-Pflicht
|
||||
|
||||
Als **P1**,
|
||||
ich möchte **ein Projekt auf „abgeschlossen" setzen können — aber nur, wenn ich die Lessons-Learned-URL angegeben habe**,
|
||||
sodass **die Leitfaden-Pflicht-Minimum-Position 6 garantiert erfüllt wird**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich bin P1 eines aktiven Projekts und öffne den Projekt-Header,
|
||||
**When** ich auf „Projekt abschließen" klicke,
|
||||
**Then** öffnet sich ein Dialog, der die Lessons-Learned-URL als Pflichtfeld einfordert (URL-Format-Validierung via Zod)
|
||||
**And** beim Submit wird `project.archiviert=true`, `archiviert_am=now()`, `lessons_learned_url` gesetzt und `pflicht_minimum_state.6=true` neu berechnet
|
||||
**And** das Projekt verschwindet sofort aus `/bereich/entwicklung` und `/puls`
|
||||
**And** Try-To-Submit ohne URL zeigt deutsche Inline-Fehlermeldung
|
||||
**And** ein nicht-P1-User sieht den „Abschließen"-Button nicht (UI + tRPC-Guard).
|
||||
|
||||
### Story 6.2: Archiv-Sicht pro Bereich
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **abgeschlossene Projekte in einer Archiv-Sicht einsehen**,
|
||||
sodass **vergangene Erkenntnisse auffindbar bleiben**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** mindestens 2 Projekte sind abgeschlossen,
|
||||
**When** ich `/archiv` öffne (oder via TopNav-Tab),
|
||||
**Then** sehe ich pro Bereich (Tabs Entwicklung/Vertrieb/Marketing) eine Liste abgeschlossener Items sortiert nach `archiviert_am` (neueste oben)
|
||||
**And** Klick auf ein Item öffnet die read-only Detail-Sicht mit allen Daten + Lessons-Learned-Link
|
||||
**And** die Archiv-Sicht hat denselben Sub-Header wie die Bereichs-Detail-Views (Sticky), aber mit zusätzlichem „Archiv"-Marker.
|
||||
|
||||
### Story 6.3: Backup-Cron, Restore-Drill-Dokumentation
|
||||
|
||||
Als **DevOps**,
|
||||
ich möchte **ein tägliches `pg_dump`-Backup mit 14-Tage-Retention und dokumentiertem Restore-Pfad**,
|
||||
sodass **Datenverlust auch nach einem schweren Vorfall begrenzt bleibt**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** der Cron-Endpoint `POST /internal/cron/pg-dump-backup` ist implementiert,
|
||||
**When** die System-Cron-Definition den Endpoint täglich 02:00 aufruft,
|
||||
**Then** wird ein gz-komprimiertes Dump-File auf einem separaten Docker-Volume `backups` geschrieben
|
||||
**And** Dumps älter als 14 Tage werden automatisch gelöscht
|
||||
**And** `docs/operations.md` enthält eine Restore-Anleitung in 5 Schritten (Stop API, Restore Dump, Migrate, Smoke-Test, Start API)
|
||||
**And** ein quartalsweiser Restore-Drill ist als Issue im Plane geöffnet (Story-Akzeptanz prüft, dass die Aufgabe dokumentiert ist, nicht den Drill selbst).
|
||||
|
||||
### Story 6.4: CI-Pipeline mit Vitest, Playwright und Lighthouse-A11y
|
||||
|
||||
Als **Entwickler**,
|
||||
ich möchte **eine CI-Pipeline, die Typecheck, Lint, Vitest, Playwright und Lighthouse-A11y-Test bei jedem PR ausführt**,
|
||||
sodass **Qualität und Barrierefreiheit nicht erodieren**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** `.gitea/workflows/ci.yml` erweitert die Pipeline,
|
||||
**When** ein PR geöffnet wird,
|
||||
**Then** läuft die Pipeline `pnpm install` → `pnpm typecheck` → `pnpm lint` → `pnpm test` (Vitest mit axe-core auf Komponenten-Snapshots) → `pnpm build` → Playwright-E2E auf den drei UJs (UJ-1, UJ-2, UJ-5) gegen eine ephemere Docker-Compose-Umgebung → Lighthouse-CI auf `/puls`, `/mein-heute`, `/projekt/:id`
|
||||
**And** Lighthouse-A11y-Score < 95 lässt die Pipeline fehlschlagen
|
||||
**And** jeder PR-Check zeigt einen Statusbericht mit den erfüllten/gefehlten Punkten.
|
||||
|
||||
### Story 6.5: Responsive-Audit und Mobile-Lesbarkeit
|
||||
|
||||
Als **Mitarbeiter**,
|
||||
ich möchte **das Cockpit auch unterwegs am Handy lesen können** (nicht schreiben — das bleibt Desktop),
|
||||
sodass **ich beim Pendeln zumindest sehe, wie es um die Projekte steht**.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** ich öffne `/puls` und `/projekt/:id` auf einem mobilen Browser (Viewport 375×667),
|
||||
**When** die Seiten geladen sind,
|
||||
**Then** sind alle Karten und Listen leserlich gestapelt (Single-Column), keine horizontalen Scrolls
|
||||
**And** die TopNav wird zu einer Bottom-Tab-Bar mit 4 Items (Puls, Mein heute, Bereiche-Dropdown, Profil)
|
||||
**And** der Einstempel-CTA auf `/mein-heute` zeigt einen sichtbaren Hinweis „Tagesupdate auf Desktop empfohlen" (Voice-konform)
|
||||
**And** Playwright-Mobile-Viewport-Tests laufen für `/puls` und `/projekt/:id` und prüfen Layout-Integrität.
|
||||
|
||||
---
|
||||
|
||||
## Validation Summary
|
||||
|
||||
### FR Coverage Check
|
||||
|
||||
| FR | Story | Status |
|
||||
|---|---|---|
|
||||
| FR-1, 1a, 2-7 | 3.1-3.4 | ✅ |
|
||||
| FR-8-11 | 4.1, 4.2 | ✅ |
|
||||
| FR-12-16 | 4.3, 4.4 | ✅ |
|
||||
| FR-17-20 | 4.5 | ✅ |
|
||||
| FR-21-25 | 2.2, 2.3 | ✅ |
|
||||
| FR-26-28, 31 | 2.4 | ✅ |
|
||||
| FR-29, 30 | 2.6, 3.4 | ✅ |
|
||||
| FR-32, 33 | 2.2, 2.4, 2.7 | ✅ |
|
||||
| FR-34-37 | 2.7 | ✅ |
|
||||
| FR-38-40 | 5.1, 5.2 | ✅ |
|
||||
| FR-41-44 | 2.5, 5.3, 5.4 | ✅ |
|
||||
| FR-45-47 | 6.1, 6.2 | ✅ |
|
||||
| FR-48 | 1.6 | ✅ |
|
||||
| FR-49 | — | ⏭ Phase 2 (explizit) |
|
||||
|
||||
**Coverage:** 48 von 49 FRs durch Stories abgedeckt; FR-49 (Volltextsuche) bewusst Phase 2 gemäß PRD § 8.
|
||||
|
||||
### NFR Coverage Check
|
||||
|
||||
- **Performance (NFR-1, 2):** 3.1 (Procedure-Budget), 3.4 (Lighthouse), 6.4 (CI-Performance-Gate)
|
||||
- **Verfügbarkeit (NFR-3, 4):** 6.3 (Backup + Restore-Drill), 1.7 (Healthcheck)
|
||||
- **Security (NFR-5, 6, 7):** 1.3 (Authentik), 1.4 (Audit-Wrapper)
|
||||
- **Bedienbarkeit (NFR-8-10):** 1.5 (Brand-Tokens), 6.5 (Mobile-Audit)
|
||||
- **Voice & Tone (NFR-11, 12, 13):** 1.5 (voice.ts) — alle UI-Stories nutzen die zentrale Datei
|
||||
- **Daten (NFR-14, 15, 16):** 1.2 (Postgres+Drizzle), 6.3 (Backup), 1.1 (Drizzle-Migrationen)
|
||||
|
||||
### Story-Quality-Validation
|
||||
|
||||
- ✅ **Sized for single dev agent:** keine Story > 1 Arbeitstag in der KI-gestützten Entwicklung.
|
||||
- ✅ **Given/When/Then-Format** auf jeder AK.
|
||||
- ✅ **Keine Vorwärts-Abhängigkeiten** — jede Story baut auf vorherigen Stories (gleicher Epic) oder bereits abgeschlossenen Epics auf.
|
||||
- ✅ **Inkrementelles Schema:** DB-Migrationen entstehen je Story-Bedarf, nicht upfront. (`0000_init`, `0001_day_rhythm` inkl. blocker, `0002_projects`, `0003_sales`, `0004_marketing`, `0005_pflicht_minimum`.)
|
||||
- ✅ **Custom-Starter** ist Story 1.1 (Repo-Bootstrap), entspricht Architektur § Initialization Commands.
|
||||
|
||||
### Epic-Independence-Check
|
||||
|
||||
| Epic | Depends on | Liefert |
|
||||
|---|---|---|
|
||||
| 1 | — | Login + Shell + Brand |
|
||||
| 2 | 1 | Tagesrhythmus inkl. Cron + Streak |
|
||||
| 3 | 1, 2 | Puls-Übersicht mit Vortrags-Feed |
|
||||
| 4 | 1 (unabhängig von 2/3 möglich) | Projekt-/Deal-/Kampagnen-Domain |
|
||||
| 5 | 4 (Projekte) + 2 (Blocker-Inline) | Pflicht-Minimum + Blocker-Lifecycle |
|
||||
| 6 | 4 (Archiv), 2 (Cron-Pattern) | Abschluss + Production-Pipeline |
|
||||
|
||||
✅ Jedes Epic liefert eigenständig nutzbaren Wert.
|
||||
|
||||
### File-Churn-Check
|
||||
|
||||
- Epic 2 und Epic 5 berühren beide `blocker`-Schema und -Komponente — bewusst getrennt: Epic 2 etabliert nur Inline-Erfassung in der Reflexion, Epic 5 fügt vollständigen Lifecycle (Standalone-Button + P1-Auflösung + Audit-Tab) hinzu. Aufteilung mitigiert Risiko, dass das volle Blocker-System verzögert wird, bevor der Tagesrhythmus läuft.
|
||||
- Epic 3 und Epic 4 berühren beide `apps/api/src/routers/puls.ts` und KPI-Slots im Frontend — getrennt, weil Epic 3 die Aggregation für leere Bereiche etabliert (skelettiert) und Epic 4 die Domain-Daten dahinter liefert. Konsolidierung hätte ein zu großes Epic ohne Zwischen-Release-Wert ergeben.
|
||||
|
||||
### UX-DR-Coverage-Spot-Check
|
||||
|
||||
- UX-DR1-4 (Brand-Tokens, Typo, Spacing, Container) → Story 1.5
|
||||
- UX-DR5-7 (Button, Card, Input, Textarea, Select, Checkbox) → Story 1.5 + 1.6 (verteilt, je nach Bedarf in Folge-Stories)
|
||||
- UX-DR8 (AmpelButton, TrendButton) → Story 2.4
|
||||
- UX-DR9 (Dialog, Tooltip) → Story 4.1 + 6.1
|
||||
- UX-DR10 (SkeletonCard) → Story 3.2
|
||||
- UX-DR11 (AmpelBadge) → Story 3.2 / 4.2
|
||||
- UX-DR12 (PflichtMinimumIndicator) → Story 5.2
|
||||
- UX-DR13 (KeyCaret) → Story 1.6 + 2.7
|
||||
- UX-DR14-16 (Navigation, Breadcrumbs, Shortcuts) → Story 1.6
|
||||
- UX-DR17 (voice.ts) → Story 1.5
|
||||
- UX-DR18-20 (User Journeys) → Stories 2.2/2.4, 3.2, 4.1
|
||||
- UX-DR21-25 (Responsive + A11y) → Story 6.4 + 6.5
|
||||
- UX-DR26-30 (Interaktions-Patterns) → verteilt über alle UI-Stories
|
||||
|
||||
✅ Alle 30 UX-DRs sind in Stories adressiert.
|
||||
|
||||
### Validation Verdict
|
||||
|
||||
**🟢 READY FOR DEVELOPMENT**
|
||||
|
||||
- 49 FRs · 16 NFRs · 16 AR-Items · 30 UX-DRs → durch 6 Epics und 32 Stories abgedeckt
|
||||
- Keine Vorwärts-Abhängigkeiten, keine Upfront-Big-Bang-Migrationen
|
||||
- Epic-Reihenfolge entspricht der Architektur-Implementation-Sequence
|
||||
- Bauen kann mit **Story 1.1 (Repo-Bootstrap)** sofort starten.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# Entscheidungs-Log – PRD projectmanagement
|
||||
|
||||
> Kanonisches Gedächtnis und Audit-Trail dieses PRD-Laufs. Jede Entscheidung, Änderung
|
||||
> und jeder Override (auch headless) wird hier festgehalten, während das Gespräch läuft.
|
||||
|
||||
## Eintraege
|
||||
|
||||
- **2026-05-25** – Lauf gestartet. Intent: *Create*. Workspace: `prd-projectmanagement-2026-05-25/`.
|
||||
Sprache: Deutsch (communication & document_output). Facilitator: Claude (bmad-prd).
|
||||
- **2026-05-25** – Inputs eingelesen: `inputs/unternehmens-cockpit.html` (HTML-Prototyp,
|
||||
1214 Zeilen, von Nicht-Techniker als Vibe-Code-Ausgangspunkt erstellt). Extract liegt in
|
||||
`extract-prototype-html.md`. ToolCTRL Brand Identity referenziert (Pfad ausserhalb Repo,
|
||||
nur als Referenz; konkrete Werte werden in der UX-Phase festgezogen).
|
||||
- **2026-05-25** – Working Mode: **Fast Path**. Verbleibende Gaps werden gebündelt
|
||||
abgefragt, dann Draft mit `[ASSUMPTION]`-Tags. User reviewt und iteriert.
|
||||
- **2026-05-25** – Team-Skalierung: **6-15 Mitarbeiter** (gesamtes ToolCTRL-Team).
|
||||
- **2026-05-25** – Auth-Strategie: **Authentik (auth.tool-ctrl.de) via OIDC**. Wird als
|
||||
weitere App im bestehenden SSO-Setup registriert. Konsistent mit Plane, Outline, Gitea,
|
||||
Kimai, Nextcloud, Element, Jitsi etc., die alle schon hinter Authentik laufen.
|
||||
- **2026-05-25** – Plane/Outline-Integration: **Phase 2, nicht MVP**. Im MVP nur freie
|
||||
URL-Links als Strings im Feld `Bezug`. Später API-Sync möglich (auch unter Authentik).
|
||||
- **2026-05-25** – Tech-Stack-Default für PRD: **React (Frontend) + Postgres (Datenhaltung)**;
|
||||
Backend-Wahl bleibt der Architektur-Phase überlassen. Tech-Details landen in
|
||||
`addendum.md`, das PRD selbst bleibt Capability-orientiert.
|
||||
- **2026-05-25** – Areas-Scope MVP: **nur 3 Detail-Areas** (Entwicklung, Vertrieb,
|
||||
Marketing). Buchhaltung & Design können Tagesupdates mit Bezug "allgemein" schreiben
|
||||
und erscheinen im Puls als Update-Quote / Streak, bekommen aber keine eigene Detail-View.
|
||||
Erweiterung in Phase 2.
|
||||
- **2026-05-25** – Kern-Mechanik gegen Versandung: **kombinierter Ansatz** —
|
||||
(a) Daily-Update-Pflicht mit öffentlicher "fehlt heute"-Liste & Streak,
|
||||
(b) Deadline-/Meilenstein-Verfall als Bar-Visualisierung pro Projekt,
|
||||
(c) bereichsübergreifende Blocker-Liste im Puls. Diese drei zusammen sind das "Warum
|
||||
jetzt"-Kernversprechen des Produkts.
|
||||
- **2026-05-25** – **Major Update: Leitfaden v3.0 als bindender Frame nachgereicht**
|
||||
(`inputs/ToolCTRL_Projektmanagement-Leitfaden_v3.0.pdf`, Mai 2026).
|
||||
Konsequenzen, die im PRD und Addendum eingepflegt sind:
|
||||
- PRD-Frontmatter `binding_frame` gesetzt.
|
||||
- Tool-Aufteilung verschärft: Plane=Arbeit, Outline=Gedächtnis, Cockpit=Übersicht
|
||||
(Anti-Ziele A3, A4, A5 ergänzt).
|
||||
- Ampel-Definitionen exakt aus Leitfaden §5: Grün/Gelb/Rot mit klarer
|
||||
Semantik, plus Trend (besser/gleich/schlechter) + Freitext max 1000 Zeichen.
|
||||
- **Neues Major-Feature**: Feature-Gruppe G "Pflicht-Minimum-Check" (FR-30/30a/30b)
|
||||
leitet sich direkt aus Leitfaden §9 ab; im Puls (FR-7) sichtbar.
|
||||
- Erfolgsmetrik M6 ergänzt (≥90% Projekte erfüllen alle 6 Pflicht-Punkte).
|
||||
- Rollen umstrukturiert: §4.1 Leitfaden-Rollen (P1-P4) + §4.2 Querschnittsrollen
|
||||
(Q1-Q5). Konsistent mit Leitfaden §7.
|
||||
- Projektarten umbenannt: Kundenprojekt / Internes Projekt (statt
|
||||
Umsatz/Intern aus Prototyp). Konsistent mit Leitfaden §1.
|
||||
- Datenmodell-Skizze erweitert: `Project.kind`, `arbeitsmodus`, `dod_text`,
|
||||
URL-Felder, `pflicht_minimum_state`, `Deal.probability`,
|
||||
`Deal.won_at/won_value_eur`, neue `LeadHandover`-Entität.
|
||||
- Addendum §7 "Leitfaden-Bezüge"-Tabelle ergänzt.
|
||||
- **2026-05-25** – Reconciliation-Subagent Findings eingearbeitet:
|
||||
- FR-13: `probability` ergänzt; FR-14: gewichteter Pipeline-Wert + Won-Deals-KPI;
|
||||
- FR-19: Crosslink-Banner Marketing↔Vertrieb;
|
||||
- NFR-12/13: neue Sektion **Voice & Tone** mit Prototyp-Anker-Phrasen;
|
||||
- Vokabular im Addendum §6 um "Update-Serie", "Crosslink",
|
||||
"Bereichsübergreifend offen", "Trend" erweitert.
|
||||
- **2026-05-25** – Rubric-Walker Verdict: **good** (0 Critical, 0 High,
|
||||
3 Medium, 5 Low). Findings teilweise sofort eingearbeitet:
|
||||
- FR-1a: Ampel-Aggregations-Algorithmus pro Area expliziert.
|
||||
- FR-5: Key-Metrics als verbindlicher Katalog pro Area (nicht mehr Beispiele).
|
||||
- §5 UJ: Persona-Linkage via P1-P4 / Q1-Q5-IDs.
|
||||
- FR-23: Sortierung neueste oben + Pagination 50/Seite.
|
||||
- NFR-16: Bound "max. 2-stündige Migration".
|
||||
- §10: Annahmen-Index ergänzt (A-1 bis A-6).
|
||||
- OQ-4 als Phase-Blocker markiert.
|
||||
Rest (OQ-3 Bestätigung, OQ-7 Pflicht/Optional Trend, "Bereich/Area"-Synonymie) →
|
||||
Triage mit User.
|
||||
- **2026-05-25** – Vorläufige Defaults für offene Fragen im PRD eingetragen:
|
||||
OQ-3 Default = "rückwirkende Updates gesperrt"; OQ-4 Default = "jeder darf
|
||||
Blocker als gelöst markieren"; OQ-6 Default = "manuelle Checkbox im MVP";
|
||||
OQ-7 Default = "Trend optional". Alle bestätigungsbedürftig in Triage.
|
||||
- **2026-05-25** – **Major Konzept-Erweiterung: Morgen-ToDos + Abend-Reflexion.**
|
||||
User hat in der Triage einen bisher nicht erwähnten Kern-Workflow nachgereicht:
|
||||
Tagesupdate ist nun ein zweistufiger Vorgang — morgens stempelt die Person sich
|
||||
ein und trägt ihre ToDos für den Tag ein; abends ruft sie die Liste auf, hakt ab,
|
||||
schreibt eine Reflexion, setzt Ampel + Trend. Kollegen sehen den konsolidierten
|
||||
Eintrag erst am nächsten Werktagsmorgen. Innerhalb des laufenden Tages sehen
|
||||
Kollegen nur binäre Anwesenheit, keine ToDo-Inhalte.
|
||||
Konsequenzen im PRD:
|
||||
- UJ-1 komplett umgeschrieben (Morgen/Abend/Nächster Morgen).
|
||||
- Feature-Gruppe E umgebaut: 6.5.1 Morgen (FR-21-25), 6.5.2 Abend (FR-26-28),
|
||||
6.5.3 Konsolidierung (FR-29-33). FRs danach von 26-39 auf 34-49 renumbered.
|
||||
- Tages-Status pro Person (voll/halb/nicht angefangen/abwesend) (FR-33).
|
||||
- Streak-Definition geändert: zählt nur volle Tage (FR-34).
|
||||
- CM1 aufgesplittet in CM1a (Morgen ≤60s), CM1b (Abend ≤90s), CM1 (Gesamt ≤3 min).
|
||||
- Sichtbarkeitsmodell: `published_at` = nächster Werktagsmorgen.
|
||||
- Addendum: neues Datenmodell `DayEntry` ersetzt `Update`, neue Entität `Todo`,
|
||||
Vokabular um Einstempeln/Reflexion/„Mein heute"/Konsolidierter Vortagseintrag
|
||||
erweitert.
|
||||
- Beschlossene OQs aus Triage: OQ-3 (1 Werktag zurück), OQ-4 (nur P1),
|
||||
OQ-6 (manuelle Checkbox), OQ-7 (Trend Pflicht bei Projekt-Bezug).
|
||||
- Neue OQs offen: OQ-8 (Erinnerungs-Zeit Reflexion, Default 17:00),
|
||||
OQ-9 (≥1 ToDo Pflicht beim Einstempeln), OQ-10 (ToDos nach Reflexion noch
|
||||
nachtragbar — Default: nein).
|
||||
- **2026-05-25** – Polish-Pass durchgeführt durch zwei parallele Subagenten
|
||||
(Struktur + Prosa) auf prd.md und addendum.md. Wichtigste Änderungen:
|
||||
Vereinheitlichung „Area" → „Bereich"; CM-Tabelle nach Gesamtzeit zuerst,
|
||||
dann Teilzeiten sortiert; Tippfehler bereinigt; Vokabular synchronisiert
|
||||
(„Tagesupdate" durchgängig). Keine FRs/Anti-Ziele/Metriken inhaltlich
|
||||
geändert. PRD: 29 206 → 28 633 Zeichen (–2%). Addendum: 9 500 → 9 350
|
||||
Zeichen (–1,5%).
|
||||
- **2026-05-25** – **Finalize abgeschlossen.** Frontmatter `status: final` in
|
||||
prd.md und addendum.md gesetzt. Phase-Blocker-OQs (OQ-1 Authentik-Mapping,
|
||||
OQ-2 Brand-Tokens, OQ-4 Blocker-Auflöser) sind im PRD entschieden oder an
|
||||
konkrete Folge-Phase deferiert. Externe Handoffs (Confluence/Notion) sind im
|
||||
Skill-Config leer — entfallen. Empfehlung für nächsten Schritt: parallel
|
||||
bmad-create-architecture (technische Schnitt klar, Datenmodell skizziert)
|
||||
und bmad-create-ux-design (Brand-Tokens, „Mein heute"-Konzept, Voice & Tone).
|
||||
bmad-create-epics-and-stories danach.
|
||||
- **2026-05-25** – **Architektur-Phase abgeschlossen.** Dokument liegt unter
|
||||
`_bmad-output/planning-artifacts/architecture.md` (8 von 8 Schritten erledigt,
|
||||
Status complete). Wichtigste Pivots:
|
||||
- **Stack ist Vite + React (SPA), nicht Next.js** — auf Team-Wunsch
|
||||
(T3-Stack-Vorschlag aus Step 3 verworfen).
|
||||
- Monorepo (pnpm workspaces): `apps/web`, `apps/api`, `packages/db`, `packages/shared`.
|
||||
- Backend: Fastify 5+ mit tRPC v11, `openid-client` v6+ für Authentik OIDC,
|
||||
Drizzle ORM auf Postgres 16+, Session-Cookies (HttpOnly+SameSite=Lax) statt JWT.
|
||||
- Background-Jobs: System-Cron → authentifizierter Fastify-Endpoint
|
||||
(`/internal/cron/*` mit Bearer-Token), 4 Jobs definiert.
|
||||
- Hosting: bestehende ToolCTRL-Infrastruktur, Caddy 2.x als Reverse-Proxy.
|
||||
- 4 tRPC-Procedure-Typen: `public`, `protected`, `dayEntryVisible`, `projectOwner`.
|
||||
- OQ-5 (Archivierungs-Policy) in der Architektur fixiert: Soft-Delete via
|
||||
`archiviert`-Flag, kein automatisches Löschen.
|
||||
- Verdict der Validierung: **READY FOR IMPLEMENTATION** (16/16 Checklist,
|
||||
0 Critical, 3 Important nicht-blockierende Gaps).
|
||||
- Folgeskill: `bmad-create-ux-design` (OQ-2 Brand-Tokens, „Mein heute"-Layout,
|
||||
Voice & Tone), danach `bmad-create-epics-and-stories`.
|
||||
- **2026-05-25** – **UX-Design-Phase abgeschlossen.** Dokument liegt unter
|
||||
`_bmad-output/planning-artifacts/ux-design-specification.md` (14 von 14 Schritten
|
||||
erledigt, status: complete). Hauptentscheidungen:
|
||||
- **Brand-Tokens fixiert (OQ-2 geschlossen):** Weiß-Schwarz-Monochrom mit drei
|
||||
Ampel-Akzentfarben für Status; konkrete Hex-Werte und Typo-Scale dokumentiert.
|
||||
- **Typografie:** Inter (UI) + JetBrains Mono (Werte), selbst-gehostet via
|
||||
`@fontsource`. Konsistent mit Brand-Marketing-Style.
|
||||
- **Component-Library:** Tailwind + Radix UI Primitives + 15 hauseigene Brand-
|
||||
konforme Komponenten in `components/ui/`. Kein shadcn-Bulk, kein Material UI.
|
||||
- **Design-Richtung:** „Office Minimal" — ruhiges, typografie-getragenes Layout,
|
||||
Brand-Reduktion als Feature. Drei Sichtebenen: Desktop (Volllayout), Tablet
|
||||
(2-Spalten), Mobile (lesbar, schreiben auf Desktop empfohlen).
|
||||
- **Voice & Tone konkretisiert:** „Stark.", „Wie läuft es?", „Du hast heute noch
|
||||
nicht gepingt." als Mikrocopy-Anker; keine Emojis, kein Corporate-Sprech,
|
||||
deutsch durchgängig.
|
||||
- **Brand-Element `^`:** Das macOS-Ctrl-Caret als wiederkehrendes Element
|
||||
(Logo-Lockup `cockpit ^`, Streak-Badges, Toast-Bestätigungen).
|
||||
- **Mermaid-Diagramme** für UJ-1 bis UJ-5 in der Spec.
|
||||
- **A11y-Strategie:** WCAG 2.1 AA, Keyboard-First, axe-core-Tests, Lighthouse-
|
||||
Ziel ≥ 95.
|
||||
- Folgeskill: **`bmad-create-epics-and-stories`** — übersetzt PRD + Architektur
|
||||
+ UX-Spec in epische User-Stories mit Akzeptanzkriterien.
|
||||
- **2026-05-25** – **Epics & Stories-Phase abgeschlossen.** Dokument liegt unter
|
||||
`_bmad-output/planning-artifacts/epics.md` (4 von 4 Schritten erledigt,
|
||||
status: complete). User-Direktive: „verkünstel dich nicht zu sehr in der
|
||||
Planung — wir müssen mit dem Bauen anfangen." Daher schlanke Struktur:
|
||||
**6 Epics** entlang der Architektur-Implementation-Sequence, **32 Stories**
|
||||
insgesamt mit Given/When/Then-AKs. Schema wird inkrementell pro Story-Bedarf
|
||||
migriert (kein Big-Bang). Coverage: 48/49 FRs, alle NFRs/AR/UX-DRs abgedeckt;
|
||||
FR-49 (Volltextsuche) explizit Phase 2. **Verdict: READY FOR DEVELOPMENT —
|
||||
bauen startet mit Story 1.1 (Repo-Bootstrap).**
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: Addendum – ToolCTRL Unternehmens-Cockpit
|
||||
status: final
|
||||
created: 2026-05-25
|
||||
updated: 2026-05-25
|
||||
binding_frame: ToolCTRL Projektmanagement-Leitfaden v3.0 (Mai 2026)
|
||||
---
|
||||
|
||||
# Addendum zum PRD – ToolCTRL Unternehmens-Cockpit
|
||||
|
||||
> Inhalte, die im Hauptdokument den Lesefluss stören würden, aber für Architektur, UX-Design und das spätere Verständnis von Entscheidungen wichtig sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Technische Orientierung (vorläufig)
|
||||
|
||||
### 1.1 Stack-Default
|
||||
- **Frontend:** React.
|
||||
- **Datenhaltung:** Postgres.
|
||||
- **Backend:** offen — Node/Express oder Fastify (passt zur React-Welt), Python/FastAPI, oder .NET. Entscheidung in Architektur-Phase.
|
||||
- **Auth:** Authentik (auth.tool-ctrl.de) via OIDC. Cockpit als weitere App im Authentik-Provider; Token-Validierung im Backend.
|
||||
- **Hosting:** wahrscheinlich gleiche interne Infrastruktur wie Plane/Outline/Gitea.
|
||||
|
||||
### 1.2 Datenmodell-Skizze (nicht normativ)
|
||||
|
||||
```
|
||||
User
|
||||
id, authentik_sub, name, email, default_area_id, active, abwesend_bis
|
||||
|
||||
Area
|
||||
id, name, kind (dev|sales|marketing|allgemein), order
|
||||
|
||||
Project
|
||||
id, area_id, name,
|
||||
kind (kundenprojekt|internes_projekt) # Leitfaden §1
|
||||
verantwortliche_user_id # Leitfaden §7 (P1)
|
||||
arbeitsmodus (cycle|kanban) # Leitfaden §5
|
||||
outline_steckbrief_url
|
||||
steckbrief_freigegeben (bool, manuell)
|
||||
plane_project_url
|
||||
risikoliste_url (nullable)
|
||||
dod_text (nullable, Freitext) # Leitfaden §4
|
||||
start, deadline,
|
||||
status_cache (green|amber|red|null)
|
||||
trend_cache (besser|gleich|schlechter|null)
|
||||
pflicht_minimum_state (jsonb mit 6 booleans) # Leitfaden §9
|
||||
archiviert (bool), archiviert_am, lessons_learned_url (nullable)
|
||||
created_by, created_at
|
||||
|
||||
Milestone
|
||||
id, project_id, title, due, status (open|done|overdue), description
|
||||
|
||||
Deal
|
||||
id, area_id (=sales), name, owner_id, value_eur,
|
||||
probability (0-100), # für gewichtete Pipeline
|
||||
stage (lead|erstgespraech|demo|angebot|abschluss),
|
||||
entered_stage_at,
|
||||
won_at (nullable), won_value_eur (nullable)
|
||||
|
||||
GtmStep
|
||||
id, product, title, owner_id, status (open|in_progress|done), order
|
||||
|
||||
Campaign
|
||||
id, area_id (=marketing), name, status (active|paused|done),
|
||||
reach, owner_id
|
||||
|
||||
LeadHandover # Crosslink Marketing → Sales
|
||||
id, campaign_id, dealkette_deal_ids (array),
|
||||
übergeben_am, übergeben_von
|
||||
|
||||
DayEntry # ersetzt das alte "Update"
|
||||
id, user_id, date,
|
||||
status (begun|full|absent), # begun = nur Einstempeln; full = Einstempeln + Reflexion
|
||||
checked_in_at, # Zeitstempel Morgen-Einstempeln
|
||||
reflected_at (nullable), # Zeitstempel Abend-Reflexion
|
||||
reflection_text (nullable, max 1000 Zeichen),
|
||||
ampel (green|amber|red|null),
|
||||
trend (besser|gleich|schlechter|null),
|
||||
url (nullable),
|
||||
blocker_id (nullable),
|
||||
edit_locked_at (nullable), # 30 Min nach Reflexion
|
||||
published_at # nächster Werktag 00:00 — ab dann für Kollegen sichtbar
|
||||
|
||||
Todo
|
||||
id, day_entry_id, user_id, date,
|
||||
title (max 200), order,
|
||||
bezug_kind (project|deal|campaign|allgemein|null),
|
||||
bezug_id (nullable),
|
||||
status (open|done|dropped),
|
||||
drop_reason (nullable, Freitext),
|
||||
created_at, completed_at, dropped_at
|
||||
|
||||
# Hinweis: ein Tagesupdate (DayEntry) hat 0..N Todos plus einen einzigen
|
||||
# Reflexionsblock. Vor `published_at` sehen nur die Person selbst und
|
||||
# Projektverantwortliche (für Todos mit Projekt-Bezug) die Inhalte.
|
||||
|
||||
Blocker
|
||||
id, title, description, area_id, project_id (nullable),
|
||||
owner_id, opened_by, opened_at,
|
||||
resolved_by, resolved_at, resolution_text
|
||||
|
||||
AbsenceDay
|
||||
id, user_id, date, reason (urlaub|krank|sonstiges)
|
||||
|
||||
AuditEvent
|
||||
id, actor_user_id, action, entity_kind, entity_id, payload_jsonb, created_at
|
||||
```
|
||||
|
||||
### 1.3 Auth-Flow
|
||||
1. User öffnet `cockpit.tool-ctrl.de`.
|
||||
2. Backend prüft Session-Cookie; bei Fehlen Redirect auf Authentik-Authorize-Endpoint.
|
||||
3. Authentik liefert OIDC-Code, Cockpit-Backend tauscht ihn gegen Token (Authorization Code Flow + PKCE).
|
||||
4. Backend legt User in eigener Tabelle an, falls erstmaliger Login (Mapping per `sub`).
|
||||
5. Default-Area wird aus Authentik-Group-Claim gemappt.
|
||||
|
||||
### 1.4 Phase 2 – Plane/Outline-Integration (Skizze)
|
||||
- **Plane:** API-Token; bei Eingabe einer Plane-URL holt das Backend Titel, Status und Owner und cached sie. Pflicht-Minimum-Punkt 3 (Plane-Board) wird damit automatisch validiert.
|
||||
- **Outline:** bei Eingabe einer Steckbrief-URL prüft das Backend per Outline-API, ob das Dokument existiert und welchen Status es hat (Draft/Published). Pflicht-Minimum-Punkt 1 wird damit automatisch validiert.
|
||||
- **Lessons-Learned-Vorlage:** beim Projekt-Abschluss erzeugt das Cockpit eine vorbefüllte Outline-Seite gemäß Leitfaden-Vorlage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verworfene Alternativen
|
||||
|
||||
### 2.1 „Bleiben wir bei Plane allein, sparen uns das Cockpit"
|
||||
- **Verworfen, weil:** Plane ist ein Tickets-Tool. Es zwingt nicht zu täglichen Updates und aggregiert nicht über Bereiche hinweg. Der Schmerz „wir sehen nicht, was läuft" bleibt ungelöst. Das Cockpit ist im Leitfaden §5 zudem explizit vorgesehen.
|
||||
|
||||
### 2.2 „Zurück zu Jira+Confluence"
|
||||
- **Verworfen, weil:** schon einmal aufgegeben. Plane/Outline sind die strategisch gewollten Nachfolger.
|
||||
|
||||
### 2.3 „Slack/Element-Channel mit Daily-Standup-Bot"
|
||||
- **Verworfen, weil:** Chat skaliert nicht zu Übersicht. Visualisierung (Puls, Funnel, Kanban) und Aggregation fehlen; Suche und Filter sind schwach.
|
||||
|
||||
### 2.4 „SaaS-Lösung kaufen (Range, Geekbot, Standuply, etc.)"
|
||||
- **Verworfen, weil:** externe SaaS bedeutet weiteren Anbieter, Datenabfluss und Auth-Insel. ToolCTRL bevorzugt Self-Hosting hinter Authentik. Keine der untersuchten Lösungen trifft die ToolCTRL-Spezifika (Pflicht-Minimum-Check aus Leitfaden, Crosslinks, GTM-Schritte).
|
||||
|
||||
### 2.5 „localStorage / Single-Page, kein Backend"
|
||||
- **Verworfen, weil:** Multi-User braucht Server-Persistenz.
|
||||
|
||||
### 2.6 „Alle 5 Bereiche mit Detail-Views im MVP"
|
||||
- **Verworfen, weil:** Buchhaltung und Design haben keine etablierten KPI-Strukturen. Detail-Views wären leere Skelette. Phase 2 nach Definition.
|
||||
|
||||
### 2.7 „Steckbrief-Editor ins Cockpit holen — One-Stop-Shop"
|
||||
- **Verworfen, weil:** Leitfaden §8 ist explizit: „Wir führen kein zusätzliches Projektmanagement-Tool ein." Outline ist das Gedächtnis. Doppel-Storage erodiert die Tool-Aufteilung.
|
||||
|
||||
### 2.8 „Plane-Issues spiegeln, damit man im Cockpit Tickets sieht"
|
||||
- **Verworfen, weil:** Plane ist „jederzeit die Wahrheit" (Leitfaden §5). Ein Spiegel driftet auseinander. Wir verlinken auf Plane.
|
||||
|
||||
### 2.9 „CRUD für Projekte komplett aus dem Cockpit raus, nur API-Sync"
|
||||
- **Verworfen, weil:** im MVP gibt es noch keinen API-Sync. Pragmatik vor Reinheit: das Cockpit pflegt eine reduzierte Sicht (Name, Verantwortliche/r, URLs, grobe Meilensteine), bis Phase 2 kommt. Die Doppelpflege wird offen kommuniziert und gemessen (CM4).
|
||||
|
||||
---
|
||||
|
||||
## 3. In-Depth-Personas (für UX-Phase)
|
||||
|
||||
### 3.1 Lukas, Entwickler, 28
|
||||
- Macht morgens Code, will das Cockpit in unter 30 s pro Tag bedienen.
|
||||
- Wechselt häufig zwischen 2–3 Projekten am Tag.
|
||||
- Lästert intern, wenn das Tool ihn „checken" lässt. Sichtbar zu machen, dass Kollege X seit 5 Tagen blockt, empfindet er als hilfreich.
|
||||
|
||||
### 3.2 Dejan, Owner / Auftraggeber (P4), oft auch P1, 35
|
||||
- Will im Jourfixe nicht mehr fragen „wie sieht's bei X aus" — sondern direkt klicken.
|
||||
- Liest den Puls 2× täglich, schreibt selbst 1× Tagesupdate mit Bezug „Allgemein" oder zu einem Projekt.
|
||||
- Größte Sorge: dass das Cockpit-Projekt das gleiche Schicksal erleidet wie Jira/Confluence zuvor.
|
||||
|
||||
### 3.3 Sales-Kollege (Q2), 30+
|
||||
- Lebt im Kanban-Tab; bewegt 5–10 Karten pro Woche.
|
||||
- Schreibt Tagesupdates ungern, wenn das Schreiben träge ist — daher CM1-Schwellwert (≤ 60 s Median).
|
||||
- Will den eigenen Funnel-Snapshot stolz zeigen können — Marketing-Crosslink (FR-19) ist UI-relevant.
|
||||
|
||||
### 3.4 Marketing-Kollege (Q3)
|
||||
- Aktualisiert wöchentlich Kampagnen, weniger täglich.
|
||||
- Die tägliche Update-Pflicht ist die ungewohnteste Anforderung — UX muss kurze „heute kein Fortschritt, aber dran"-Updates erlauben.
|
||||
|
||||
### 3.5 Designerin / Buchhaltung (Q4, Q5)
|
||||
- Schreiben Tagesupdates mit Bezug „Allgemein". Die UI darf sie nicht zwingen, ein Projekt zu wählen.
|
||||
- Bereich = ihr eigener (Authentik-Group), Bezug-Default = „Allgemein".
|
||||
|
||||
---
|
||||
|
||||
## 4. Mechanik-Detail: Wie wir „Versandung" wirklich messen
|
||||
|
||||
Vier Indikatoren zusammen:
|
||||
|
||||
1. **Update-Quote pro Person/Tag** — direkter Disziplin-Indikator. Sinkt sie unter 70 %, ist die Cockpit-Kultur erodiert.
|
||||
2. **Median-Update-Alter pro Projekt** — steigt der Median über 7 Werktage, hat das Projekt aufgehört zu leben.
|
||||
3. **Anteil Meilensteine mit Status `overdue`** — steigt er über 30 %, sind Planung und Realität auseinandergedriftet.
|
||||
4. **Pflicht-Minimum-Erfüllungsquote** (M6) — die Leitfaden-Brücke. Sinkt sie, geht es nicht nur am Status, sondern an der Methodik selbst.
|
||||
|
||||
Diese vier werden im Puls aggregiert und in der 4-Wochen-Retro besprochen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Off-Brand-Risiken des Prototyps
|
||||
|
||||
Der HTML-Prototyp (`inputs/unternehmens-cockpit.html`) zeigt das gewünschte Konzept gut, trifft aber visuell nicht die Markenrichtung:
|
||||
|
||||
- Hintergrund: Hellgrau (`#eceef3`) — soll weiß sein.
|
||||
- Header: dunkles Navy — passt nicht zu „weiß primär, schwarz sekundär".
|
||||
- Akzentfarbe: Indigo (`#4f46e5`) — durch ToolCTRL-Brand-Akzent ersetzen.
|
||||
- Ampelfarben (Grün/Amber/Rot): inhaltlich nötig, an die Brand-Palette adaptieren, ohne Funktion zu verlieren.
|
||||
|
||||
Die UX-Phase muss das Re-Skinning explizit durchführen — der Prototyp ist Logik-Referenz, keine Visual-Referenz. Die Voice-Anker aus dem Prototyp („Stark.", „Wie läuft es?", „Welt der Übersicht", „Willkommen, [Name]") sind zu bewahren — siehe NFR-12/13 im PRD.
|
||||
|
||||
---
|
||||
|
||||
## 6. Konzept-Vokabular
|
||||
|
||||
| Begriff | Bedeutung im Produkt | Quelle |
|
||||
|---|---|---|
|
||||
| **Puls** | Startseite — Unternehmens-Übersichtspuls. | Prototyp |
|
||||
| **Area / Bereich** | Fachliche Spielwiese: Entwicklung, Vertrieb, Marketing. | Prototyp + Leitfaden |
|
||||
| **Tagesupdate / DayEntry** | Kombinierter Tageseintrag einer Person: Einstempeln + ToDos + Reflexion + Ampel + Trend. | Leitfaden §5 + ToolCTRL-Workflow |
|
||||
| **Einstempeln** | Morgendliche Anwesenheits-Aktion plus Anlegen der ToDo-Liste für den Tag. | ToolCTRL-Workflow |
|
||||
| **ToDo** | Selbstgesetzter Tagesplan-Punkt mit optionalem Bezug, abends abhakbar. | ToolCTRL-Workflow |
|
||||
| **Reflexion** | Abendlicher zweiter Touchpoint: ToDos abhaken, Freitext, Ampel, Trend. | ToolCTRL-Workflow |
|
||||
| **„Mein heute"** | Persönliche Tagessicht (eigene ToDos, eigener Status) — privat während des Tages. | Cockpit-Konzept |
|
||||
| **Konsolidierter Vortagseintrag** | Die für Kollegen sichtbare Version eines DayEntry, publiziert am nächsten Werktagsmorgen. | Cockpit-Konzept |
|
||||
| **Bezug** | Worauf sich ein Tagesupdate bezieht: Projekt / Deal / Kampagne / Allgemein. | Prototyp |
|
||||
| **Ampel** | Grün (im Plan) / Amber (Abweichung, Plan greift) / Rot (Abweichung, kein Plan). | Leitfaden §5 |
|
||||
| **Trend** | besser / gleich / schlechter. | Leitfaden §5 |
|
||||
| **Blocker** | Strukturiertes Hindernis, bereichsübergreifend sichtbar. | Prototyp |
|
||||
| **Update-Serie / Streak** | Aufeinanderfolgende Werktage mit ≥ 1 Tagesupdate. | Prototyp |
|
||||
| **Jourfixe** | Wöchentliches Treffen, vom Puls aus moderiert (Default Di 11:00). | ToolCTRL |
|
||||
| **Crosslink** | Lead-Übergabe-Banner Marketing → Vertrieb. | Prototyp |
|
||||
| **Bereichsübergreifend offen** | Label für die aggregierte Blocker-Liste im Puls. | Prototyp |
|
||||
| **Pflicht-Minimum** | Die 6 Pflichtpunkte pro Projekt. | Leitfaden §9 |
|
||||
| **Projektsteckbrief** | Eckdaten-Dokument in Outline. Das Cockpit verlinkt darauf. | Leitfaden §3 |
|
||||
| **Zieldreieck** | Leistung & Umfang · Zeit · Aufwand. | Leitfaden §1 |
|
||||
| **Meilenstein** | Überprüfbares Zwischenergebnis am Phasen-Ende. | Leitfaden Anhang |
|
||||
| **Baseline** | Freigegebener Bezugsstand (Steckbrief nach Freigabe). | Leitfaden Anhang |
|
||||
| **Scope Creep** | Schleichende, unkontrollierte Umfangserweiterung. | Leitfaden Anhang |
|
||||
| **Definition of Done (DoD)** | Festlegung, wann etwas wirklich „fertig" ist. | Leitfaden Anhang |
|
||||
| **Module / Issue / Sub-Issue** | Plane-Hierarchie der Arbeitszerlegung. | Leitfaden §4 |
|
||||
| **Cycles / Kanban** | Zwei Arbeitsmodi der Umsetzungsphase. | Leitfaden §5 |
|
||||
| **Retrospektive / Lessons Learned** | Strukturierter Rückblick am Projektende. | Leitfaden §6 |
|
||||
| **Deal-Stage** | Lead → Erstgespräch → Demo → Angebot → Abschluss. | Prototyp |
|
||||
| **GTM-Schritt** | Go-to-Market-Aufgabe pro Produkt. | Prototyp |
|
||||
| **Lead-Funnel** | Reichweite → Website → Leads → MQL → Übergabe. | Prototyp |
|
||||
| **Projektart** | Kundenprojekt oder Internes Projekt. | Leitfaden §1 |
|
||||
| **Projektverantwortliche/r (P1)** | Eine Person pro Projekt, führt das Zieldreieck. | Leitfaden §7 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Leitfaden-Bezüge (was setzt was um)
|
||||
|
||||
Tabelle zur Nachvollziehbarkeit, welche PRD-Anforderung auf welchen Leitfaden-Paragraphen zurückgeht. Hilft Architektur- und Story-Phase, die Methodik-Anbindung zu prüfen.
|
||||
|
||||
| Leitfaden-Abschnitt | Im PRD umgesetzt durch |
|
||||
|---|---|
|
||||
| §1 Was ein Projekt ist · Projektarten | FR-8 (Projektart-Gruppierung); Datenmodell `Project.kind`. |
|
||||
| §1 Zieldreieck | Implizit in Ampel + Trend + Meilenstein-Status (kein eigenes UI). |
|
||||
| §2 Vier-Phasen-Zyklus | FR-35-37 (Abschluss-Workflow); UJ-5 (Anlegen). |
|
||||
| §3 Projektsteckbrief | A4 (kein Editor); FR-10 (Verlinkung); FR-30 Pflicht-Punkt 1. |
|
||||
| §3 SMART-Ziele | nicht explizit im Cockpit (lebt in Outline); im Leitfaden §3 als Pflicht-Inhalt des Steckbriefs referenziert. |
|
||||
| §3 Stakeholder | nicht im MVP; bleibt im Outline-Steckbrief. |
|
||||
| §4 Arbeit zerlegen | A5 (kein Spiegel des Plane-Boards); FR-30 Pflicht-Punkt 3. |
|
||||
| §4 Meilensteine | FR-11 Cockpit-Sicht; FR-30 Pflicht-Punkt 4. |
|
||||
| §4 Aufwand schätzen | Out of Scope — Kimai + Plane decken ab. |
|
||||
| §4 Risiken | FR-10 (Risikoliste-URL verlinken); Liste bleibt in Outline. |
|
||||
| §5 Arbeitsmodus (Cycles/Kanban) | Datenmodell `Project.arbeitsmodus`; angezeigt in FR-10. |
|
||||
| §5 Rhythmus → tägliche Updates im Cockpit | gesamte Feature-Gruppe E (Einstempeln · ToDos · Reflexion). |
|
||||
| §5 Status sichtbar machen (Ampel + Trend + 2-3 Sätze) | FR-26 Reflexions-Formular (Ampel, Trend, Freitext ≤ 1000 Zeichen). |
|
||||
| §5 Mit Änderungen umgehen | Out of Scope MVP; ändert nur den Outline-Steckbrief. |
|
||||
| §5 Reviews an Meilensteinen | FR-11 Meilenstein-Status; UJ-3 Verfall-Erkennung. |
|
||||
| §6 Abschluss / Lessons Learned | FR-35-37; Pflicht-Punkt 6. |
|
||||
| §7 Rollen | §4.1 PRD (Projektverantwortliche/r, Team, PO, Auftraggeber). |
|
||||
| §8 Tool-Aufteilung | A3-A5 PRD; gesamtes Strategie-Framing. |
|
||||
| §9 Pflicht-Minimum | Feature-Gruppe G (FR-30); M6; Puls-Sichtbarkeit. |
|
||||
|
||||
---
|
||||
|
||||
*Ende Addendum.*
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
# HTML-Prototyp Extract
|
||||
|
||||
Quelle: `inputs/unternehmens-cockpit.html` (1.214 Zeilen, Single-File-Vanilla-JS-Prototyp, deutsche Sprache, `TODAY="2026-05-21"` hart kodiert, Persistenz via `localStorage` Key `toolctrl_unternehmens_cockpit_v0_2`).
|
||||
|
||||
Titel der App: **tool-ctrl · Unternehmens-Cockpit — v0 Prototyp**
|
||||
Marker im UI: `v0 PROTOTYP` Pill im Header.
|
||||
|
||||
---
|
||||
|
||||
## Top-Level-Navigation / Hauptbereiche
|
||||
|
||||
Im Header gibt es **zwei Top-Level-Views** (umschaltbar via `nav button`):
|
||||
|
||||
1. **Cockpit** (Default) — zeigt Unternehmens-Puls + Detail eines ausgewählten Bereichs.
|
||||
2. **Tagesupdate** — ein Formular zum Erfassen eines neuen Tagesupdates.
|
||||
|
||||
Innerhalb der Cockpit-View gibt es **drei Bereiche** (Areas) als Sub-Navigation via "Area-Tiles":
|
||||
|
||||
- **Entwicklung**
|
||||
- **Vertrieb**
|
||||
- **Marketing**
|
||||
|
||||
Header enthält außerdem:
|
||||
- Brand-Marker (Dot + Titel + v0-Pill).
|
||||
- Live-Datum "Heute · Mo, 21. Mai 2026".
|
||||
- Button **"Demodaten zurücksetzen"** (cleart `localStorage` + lädt `SEED_UPDATES`).
|
||||
|
||||
Davor: **Intro / Splash-Screen** (`#intro`) — animierter HUD-Kreis, "Willkommen, Haris", User-Pill (HA · Vertrieb & Management · grüner Online-Dot), Enter-Button (oder Enter-Taste), dann fade-out.
|
||||
|
||||
---
|
||||
|
||||
## Module / Features (gruppiert)
|
||||
|
||||
### Unternehmens-Puls (oberster Karten-Block im Cockpit)
|
||||
- **Zweck:** schneller Überblick über alle drei Bereiche auf einen Blick + offene Querschnitts-Themen.
|
||||
- **Datenfelder pro Area-Tile:** Name, Status-Dot (green/amber/red), Status-Pill ("Im Plan" / "Gefährdet" / "Blockiert"), zwei Key-Metrics (variabel pro Bereich), Updates-Heute-Counter `sub/exp` + Progress-Bar.
|
||||
- **Area-Tile-Metriken:**
|
||||
- Entwicklung: `Projekte im Plan` (z. B. 4/6), `Meilensteine gesamt` (% erledigt).
|
||||
- Vertrieb: `Aktive Deals` (#), `Pipeline-Wert` (EUR).
|
||||
- Marketing: `Kampagnen live` (#), `Leads an Vertrieb` (# letzter Funnel-Step).
|
||||
- **UI-Patterns:** 3-Spalten-Grid mit Tiles (klickbar, `on`-State markiert ausgewählte Area), darunter zweigeteilte Foot-Sektion.
|
||||
- **Foot-Sektion:**
|
||||
- Links: "X von Y Tagesupdates sind heute eingetragen" + Big-Progress-Bar + **Jourfixe-Hinweis** ("Nächstes Jourfixe — Di, 26. Mai, 11:00 · übergeordnete Themen aller Bereiche", berechnet via `nextTuesday()`).
|
||||
- Rechts: **"Bereichsübergreifend offen"** — rote Chips für alle aktuellen Blocker (dedupliziert per area+blocker-Text, abgeschnitten >52 Zeichen). Bei keiner Blockade grüner Text "Aktuell keine offenen Blocker."
|
||||
- **Interaktionen:** Klick auf Area-Tile setzt `state.selected` und scrollt nach oben; Klick auf roten Blocker-Chip navigiert zur betroffenen Area.
|
||||
- **Status:** fertig und voll funktional.
|
||||
|
||||
### Bereichs-Detail: Entwicklung
|
||||
- **Zweck:** Konsolidierte Sicht aller Entwicklungsprojekte mit Meilensteinen und Tagesupdates.
|
||||
- **Layout:** `dhead` (Bereichs-Kopf) + 2-Spalten-Grid: **Projekt-Rail links (262px)** + **Projekt-Detail rechts**.
|
||||
- **Bereichs-Kopf (`dhead`):** Status-Dot, Bereichsname "Entwicklung", Pill, Leitung (Person), Updates-Heute, Blurb-Text ("Alle 6 Produkt- und Infrastrukturprojekte mit Status, Meilensteinen und Tagesupdates — Projekt links wählen.").
|
||||
- **Bereichs-Stats (4 Tiles):** `Projekte` (6 · "3 Umsatz · 3 Intern"), `Im Plan` (X/6), `Meilensteine` (X/Y + Bar), `Updates heute` (X/Y + Bar).
|
||||
- **Projekt-Rail:** gruppiert in **"Umsatz"** und **"Intern"**; jedes `pitem` zeigt: Status-Dot, Projektname, nächster Meilenstein-Name, Due-Text (`in N Tagen` / `heute fällig` / `N Tage überfällig` mit warn/over-Färbung), `done/total` Meilensteine, mini-Progress-Bar. `on`-State für selektiertes Projekt.
|
||||
- **Projekt-Detail (rechte Spalte):** eigener `dhead` mit Projektname (h1), Status-Pill, Gruppe-Tag ("Umsatz"/"Intern"), Leitung. Dann 4 Stats: `Status` (Pill, "aus dem letzten Update"), `Nächster Meilenstein` (Name + Datum + due-Text), `Fortschritt` (X/Y + Bar), `Updates heute` (X/Y + "Update-Serie"-Streak-Counter).
|
||||
- **Darunter 2-Spalten-Grid (1fr 290px):**
|
||||
- **Tagesupdates-Feed** (links): chronologisch nach Datum gruppiert, "Heute"-Marker; jedes Update zeigt Avatar mit Initialen + Personen-Farbe, Name, Rolle, Status-Pill, Text, optional Blocker-Box (rot). "+ Update hinzufügen" Button (oben rechts) prefillt Formular mit Area+Ref.
|
||||
- **Meilensteine-Liste** (rechts): Checkliste mit Box, Name, Fälligkeitsdatum; Zustände `done` (grün gefüllt, durchgestrichen), `next` (lila Outline), normal.
|
||||
- **Status:** fertig.
|
||||
|
||||
### Bereichs-Detail: Vertrieb
|
||||
- **Zweck:** Deal-Pipeline + Go-to-Market-Fortschritt je Produkt.
|
||||
- **Bereichs-Stats (4):** `Aktive Deals`, `Pipeline-Wert` (EUR Summe), `Gewichtet` (Σ value × Stage-Wahrscheinlichkeit), `Gewonnen` (# + EUR).
|
||||
- **Crosslink-Banner:** "X Leads hat Marketing diesen Monat in die Pipeline übergeben — Marketing-Status ansehen" (Pfeil-Icon, klickt zu Marketing).
|
||||
- **Deal-Pipeline-Board (Kanban-Style):** 5 Spalten = Stages `Lead`, `Erstgespräch`, `Demo`, `Angebot`, `Abschluss`. Spaltenkopf: Stage-Label + "N Deals · €Summe". Deal-Karten: Status-Dot, Kundenname, EUR-Wert, Produkt-Tag (Caps, z. B. "HIVEGRID"), Next-Step-Text.
|
||||
- **Vertriebsfortschritt je Produkt (GTM):** pro Produkt eine Karte mit Name, "X/Y Schritte", Progress-Bar, Schritt-Liste mit Box (Zustände `done`/`next`/`blocked`/`open`) + Tags ("als Nächstes" / "blockiert").
|
||||
- **Tagesupdates-Feed** (gleicher Feed-Style wie Entwicklung, aber mit `upd-ref`-Badge = Referenz zu Deal/Kampagne/"(allgemein)").
|
||||
- **Status:** fertig.
|
||||
|
||||
### Bereichs-Detail: Marketing
|
||||
- **Zweck:** Lead-Funnel + Kampagnen-Übersicht.
|
||||
- **Bereichs-Stats (4):** `Reichweite` (#), `Leads gesamt`, `An Vertrieb` (Übergabe-Counter), `Kampagnen live` (X von Y).
|
||||
- **Crosslink-Banner:** "Die N übergebenen Leads landen im Vertrieb — aktuelle Pipeline: €X" (klickt zu Vertrieb).
|
||||
- **Lead-Funnel-Visualisierung:** 5 Stufen (Reichweite → Website-Besucher → Leads → Qualifiziert (MQL) → An Vertrieb übergeben). Pro Zeile: Label + Sub-Label, horizontale gefüllte Bar (Breite relativ zum Maximum, Wert eingebrannt), Conversion-% zur Vorstufe ("X% Übergang"). Letzte Zeile = "handover" mit grünem Gradient.
|
||||
- **Kampagnen-Liste:** flache Liste, pro Zeile: Name + Channel (Sub-Text), Verantwortlicher, Leads-Count, Status-Pill (`läuft`, `geplant`, `ausgewertet`, `blockiert`, `pausiert` — je eigene Farbe).
|
||||
- **Tagesupdates-Feed** (gleicher Feed-Style).
|
||||
- **Status:** fertig.
|
||||
|
||||
### Tagesupdate-Formular (View 2)
|
||||
- **Zweck:** strukturierte Eingabe eines täglichen Status-Updates.
|
||||
- **Layout:** 2-Spalten (Formular-Karte 1fr + Side-Nudge 300px).
|
||||
- **Formular-Felder:**
|
||||
- `Wann` — statisch "Heute — Mo, 21. Mai 2026" (read-only Box).
|
||||
- `Wer bist du?` — `<select>` aller TEAM-Mitglieder mit "Name — Rolle".
|
||||
- `Bereich` — `<select>` Entwicklung/Vertrieb/Marketing.
|
||||
- `Bezug` (Projekt, Deal oder Kampagne) — dynamische `<select>` Optionen je nach Bereich; Marketing/Vertrieb haben zusätzlich `(allgemein)`.
|
||||
- `Was hast du heute geschafft?` — `<textarea>` (Placeholder mit Beispiel).
|
||||
- `Wie läuft es?` — 3 Status-Optionen (Cards mit Dot + Titel + Sub): **Im Plan** (grün, "läuft nach Plan") / **Gefährdet** (amber, "gerät in Verzug") / **Blockiert** (rot, "brauche Hilfe").
|
||||
- `Etwas, das dich blockiert oder wobei du Hilfe brauchst?` — optionale `<textarea>`.
|
||||
- Fehler-Slot (`#f-err`) + Submit-Button "Tagesupdate absenden".
|
||||
- **Validierung:** Person, Bereich, Text und Status sind Pflicht; Ref defaults zu `(allgemein)`.
|
||||
- **Submit-Verhalten:** pusht in `updates`-Array, persistiert in `localStorage`, springt zurück in Cockpit-View des gewählten Bereichs, zeigt Toast "Update von X erfasst · Bereich".
|
||||
- **Side-Nudge-Karte (rechts):**
|
||||
- "Heute noch offen" — pro fehlendem Update ein roter Chip "Person · Bereich" (Klick prefillt Formular). Wenn alle abgegeben: "Alle Tagesupdates sind da. Stark." (grün).
|
||||
- Trennlinie + "Der Rhythmus" — Erklärtext zum täglichen Update + Jourfixe-Termin (Di 11:00).
|
||||
- **Status:** fertig und voll funktional.
|
||||
|
||||
### Intro / Splash-Screen
|
||||
- **Zweck:** Willkommens-Animation beim Aufruf.
|
||||
- **Inhalt:** "Willkommen, Haris", User-Avatar-Pill mit Online-Dot, Enter-Button.
|
||||
- **Interaktion:** Klick auf Enter-Button oder Enter-Taste blendet aus.
|
||||
- **Status:** dekorativ, kosmetisch.
|
||||
|
||||
### Toast
|
||||
- Globale Toast-Komponente (`#toast`) am unteren Bildschirmrand für Bestätigungs-Messages (Update erfasst, Demodaten zurückgesetzt). Auto-Hide nach 3,2 s.
|
||||
|
||||
---
|
||||
|
||||
## KPI-Visualisierungen
|
||||
|
||||
Im Prototyp verwendete Visualisierungen:
|
||||
|
||||
- **Status-Ampel-Dots** (`.sdot`) in green/amber/red — überall, kleinste Atom-Komponente.
|
||||
- **Status-Pills** (`.pill`) mit Label "Im Plan" / "Gefährdet" / "Blockiert".
|
||||
- **Progress-Bars** (mehrere Varianten):
|
||||
- `.tbar` (mini, in Area-Tiles für Updates-Quote heute).
|
||||
- `.bigbar` (Puls-Foot für gesamte Update-Quote).
|
||||
- `.ministat-bar` (in Stat-Kacheln für Meilenstein- und Update-Progress).
|
||||
- `.pi-bar` (in Projekt-Rail-Items für Meilenstein-Fortschritt).
|
||||
- `.sprod-bar` (in GTM-Produkt-Karten).
|
||||
- **Meilenstein-Checkliste** (`.ms` Items) mit drei Box-Zuständen: done (grün/check), next (lila Outline mit Glow), blocked (roter Outline mit Glow); normal-State leere Box.
|
||||
- **Kanban-Pipeline-Board** mit 5 Spalten (Deal-Stages).
|
||||
- **Horizontaler Funnel** (5 Stufen, absteigende Bar-Breite proportional zum Wert, Conversion-% zwischen Stufen).
|
||||
- **Streak-Counter** (Update-Serie Tage am Stück, im Projekt-Detail).
|
||||
- **Due-Date-Färbung**: warn (amber, ≤4 Tage) / over (rot, überfällig).
|
||||
- **Gewichteter Pipeline-Wert** (Σ value × p) als KPI im Vertrieb.
|
||||
- **Conversion-Raten** (% zwischen Funnel-Stufen).
|
||||
- **Status-Rollup-Logik** (`rollup()`): aggregiert Einzel-Status zu Area-Status (≥2 rot → red, ≥1 rot oder ≥3 amber → amber, sonst green).
|
||||
|
||||
Explizit **NICHT vorhanden:** Burn-down-Charts, Status-Pie-Charts, Zeitreihen-Charts, Trend-Linien, prozentuale Donuts, Heatmaps. Alle "Charts" sind 1D-Bars oder Listen.
|
||||
|
||||
---
|
||||
|
||||
## UI-Patterns (mehrfach auftauchend)
|
||||
|
||||
- **Card** (`.card`) — weiße Box mit `border-radius:14px` + Schatten; Basis-Container für alles.
|
||||
- **Section-Title** (`.sec-title`) — Header in Cards mit optionalem Counter-Badge (`.cnt`) und rechts-bündigem Action-Button.
|
||||
- **Sect-Label** (Caps + letter-spacing) — kleine Sektionsüberschrift.
|
||||
- **Stat-Tile** (`.stat`) — kleine Metric-Box mit Label/Wert/Sub/optional Bar; meist 4er-Grid (`.stats`).
|
||||
- **Chip** (`.chip`) — runder farbiger Tag, hier in red-Variante für Blocker.
|
||||
- **Status-Pill** (`.pill`) — Pille in 3 Farben (green/amber/red).
|
||||
- **Group-Tag** (`.grp-tag`) — kleines neutrales Caps-Label (z. B. "UMSATZ", "INTERN").
|
||||
- **Avatar mit Initialen** (`.avatar`) — runder gefärbter Kreis mit 2-Buchstaben-Initialen, Farbe pro Person.
|
||||
- **Crosslink-Banner** (`.crosslink`) — gerahmte klickbare Zeile mit Pfeil-Icon für Bereich-zu-Bereich-Verweise.
|
||||
- **Feed-Item** (`.upd`) — Avatar + Name/Rolle/Ref/Status + Text + optional Blocker-Box; gruppiert nach `.fdate` (Datums-Header mit "Heute"-Highlight).
|
||||
- **Blocker-Box** (`.blocker`) — rote bezugnehmende Sub-Box innerhalb eines Feed-Items.
|
||||
- **Rail-Item** (`.pitem`) — sidebar list item mit `on`-Indikator (linker Strich) und Hover-State.
|
||||
- **Status-Option-Card** (`.sopt`) — Radio-Karten-Pattern für Status-Wahl im Formular.
|
||||
|
||||
**Keine Modals.** Es gibt im Prototyp keine Dialoge, Popovers, Tooltips oder Dropdowns außer nativen `<select>`-Elementen und Tabs.
|
||||
|
||||
---
|
||||
|
||||
## Interaktionen / Klick-Flows
|
||||
|
||||
Implementierte Mock-Interaktionen (alle via Vanilla JS / `wire()`):
|
||||
|
||||
1. **Top-Nav umschalten** (Cockpit ↔ Tagesupdate) via `[data-nav]`.
|
||||
2. **Area-Tile-Klick** wechselt aktiven Bereich (`state.selected`), scrollt nach oben.
|
||||
3. **Projekt-Rail-Item-Klick** wechselt aktives Dev-Projekt (`state.devProject`).
|
||||
4. **Blocker-Chip-Klick** (Puls-Foot) → Sprung in betroffenen Bereich.
|
||||
5. **Crosslink-Banner-Klick** → Sprung in verlinkten Bereich.
|
||||
6. **"+ Update hinzufügen"-Button** in Feed/Projekt-Detail → prefillt Formular (Area + ggf. Ref) und fokussiert Text-Feld.
|
||||
7. **"Missing"-Chip-Klick** (Side-Nudge) → prefillt Formular mit Person + Area.
|
||||
8. **Status-Option-Klick** im Formular setzt `state.formStatus` (immediate render).
|
||||
9. **Submit-Button**: validiert, fügt Update zu `updates`-Array hinzu, persistiert in `localStorage`, navigiert zurück + Toast.
|
||||
10. **"Demodaten zurücksetzen"** im Header: bestätigt + ersetzt updates mit `SEED_UPDATES`.
|
||||
11. **Intro-Splash**: Enter-Button oder Enter-Taste blendet aus.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (impliziert)
|
||||
|
||||
### `TEAM` (Mitglied)
|
||||
- `id` (string slug, z. B. "haris")
|
||||
- `name` (string)
|
||||
- `role` (string, z. B. "Vertrieb & Management", "Senior Software Engineer", "Junior Software Engineer", "Junior Infrastructure/Software Engineer", "Designer")
|
||||
- `color` (hex, Avatar-Farbe)
|
||||
|
||||
Es gibt 5 fest hinterlegte Personen. Keine echten User-Accounts/Auth.
|
||||
|
||||
### `AREA` (Bereich)
|
||||
- `id` (slug: "entwicklung" | "vertrieb" | "marketing")
|
||||
- `name` (string)
|
||||
- `lead` (TEAM-id, Bereichsleitung)
|
||||
- `members` (array of TEAM-ids)
|
||||
- `blurb` (string, Bereichsbeschreibung)
|
||||
|
||||
### `DEV_PROJECT`
|
||||
- `id` (slug)
|
||||
- `name` (string)
|
||||
- `group` ("Umsatz" | "Intern")
|
||||
- `lead` (TEAM-id)
|
||||
- `members` (array of TEAM-ids)
|
||||
- `milestones`: array von Objekten `{ name:string, done:boolean, due:ISO-Date }`
|
||||
|
||||
### `Milestone`
|
||||
- `name` (string)
|
||||
- `done` (boolean)
|
||||
- `due` (ISO-Date)
|
||||
|
||||
### `STAGE` (Vertriebs-Pipeline-Stufe)
|
||||
- `id` (slug)
|
||||
- `label` (string: "Lead", "Erstgespräch", "Demo", "Angebot", "Abschluss")
|
||||
- `p` (Abschlusswahrscheinlichkeit, float 0–1: 0.10/0.25/0.50/0.75/0.90)
|
||||
|
||||
### `DEAL`
|
||||
- `customer` (string)
|
||||
- `product` (string, derzeit "HiveGrid" oder "Asset Control")
|
||||
- `stage` (STAGE-id)
|
||||
- `value` (number, EUR)
|
||||
- `status` ("green" | "amber" | "red")
|
||||
- `next` (string, nächster Schritt / Notiz)
|
||||
|
||||
### `WON_DEAL`
|
||||
- `customer`, `product`, `value`, `closed` (ISO-Date)
|
||||
|
||||
### `SALES_GTM[product]` — pro Produkt eine Liste von GTM-Schritten
|
||||
- `name` (string)
|
||||
- `state` ("done" | "next" | "blocked" | "open")
|
||||
|
||||
### `CAMPAIGN`
|
||||
- `name` (string)
|
||||
- `channel` (string, z. B. "LinkedIn + Google Ads", "E-Mail (monatlich)", "Event / Messe", "Lokalpresse", "Content / Website", "Google Ads")
|
||||
- `status` ("laeuft" | "geplant" | "ausgewertet" | "blockiert" | "pausiert")
|
||||
- `leads` (number)
|
||||
- `owner` (TEAM-id)
|
||||
|
||||
### `FUNNEL` (5 ordered steps)
|
||||
- `label` (string)
|
||||
- `sub` (string, Erklärung)
|
||||
- `value` (number)
|
||||
|
||||
Stages: Reichweite → Website-Besucher → Leads → Qualifiziert (MQL) → An Vertrieb übergeben.
|
||||
|
||||
### `UPDATE` (Tagesupdate, das zentrale Entity)
|
||||
- `area` (AREA-id)
|
||||
- `ref` (string — Bezug; Projektname, Kundenname, Kampagnenname oder "(allgemein)")
|
||||
- `person` (TEAM-id)
|
||||
- `date` (ISO-Date)
|
||||
- `status` ("green" | "amber" | "red")
|
||||
- `text` (string, freier Text "was geschafft")
|
||||
- `blockers` (string, optional; leerer String wenn keiner)
|
||||
|
||||
Persistiert in `localStorage` Key `toolctrl_unternehmens_cockpit_v0_2`.
|
||||
|
||||
### State (UI)
|
||||
- `view` ("cockpit" | "form")
|
||||
- `selected` (AREA-id, aktive Area im Cockpit)
|
||||
- `devProject` (DEV_PROJECT-id, aktives Projekt in Entwicklungs-Rail)
|
||||
- `formPerson`, `formArea`, `formRef`, `formStatus`, `_text`, `_blockers` (Formular-State)
|
||||
|
||||
---
|
||||
|
||||
## Rollen / Rechte / Filter
|
||||
|
||||
- **Rollen** sind als freitextliche `role`-Strings hinterlegt (Vertrieb & Management, Senior/Junior Software Engineer, Junior Infrastructure/Software Engineer, Designer). **Keine** Permission-Logik, keine Rollen-Gates, kein Auth. Jeder kann sich beim Tagesupdate als beliebige Person ausgeben.
|
||||
- **Tags:** Es gibt Projekt-Gruppen-Tags ("Umsatz"/"Intern"), Produkt-Tags an Deals, Channel-Texte an Kampagnen, aber **keine freien Tags** oder Tagging-System.
|
||||
- **Filter:** Es gibt **keine expliziten Filter-UI-Elemente** (keine Suche, kein Datumsfilter, keine Personen-Filter). Filterung erfolgt nur implizit durch Area-Auswahl und Projekt-Auswahl.
|
||||
|
||||
---
|
||||
|
||||
## Vokabular / Begriffe
|
||||
|
||||
Wichtige Begriffe und ihre Verwendung:
|
||||
|
||||
- **Cockpit** — das Gesamtprodukt; auch Name der Default-View.
|
||||
- **Unternehmens-Puls** ("Puls") — Overview-Block ganz oben mit den Area-Tiles.
|
||||
- **Bereich** / **Areas** — Top-Level-Domänen (Entwicklung, Vertrieb, Marketing).
|
||||
- **Tagesupdate** — täglicher Status-Eintrag pro Person/Bereich/Bezug.
|
||||
- **Bezug** — die Sache, auf die sich ein Tagesupdate bezieht (Projekt, Deal, Kampagne, "(allgemein)").
|
||||
- **Jourfixe** — wiederkehrendes Team-Meeting (Dienstag 11:00) für übergeordnete Themen.
|
||||
- **Update-Serie** / **Streak** — aufeinanderfolgende Tage mit Updates für ein Projekt.
|
||||
- **Blocker** — explizit benannte Hindernisse in Updates; werden bereichsübergreifend aggregiert.
|
||||
- **Bereichsübergreifend offen** — Cross-Area-Blocker-Aggregation.
|
||||
- **Ampel** (green/amber/red) — Status-System; Labels: **Im Plan** / **Gefährdet** / **Blockiert**.
|
||||
- **Meilenstein** — DEV_PROJECT-Schritt mit Name + done + due-Datum.
|
||||
- **Pflichtenheft** — projektspezifischer Begriff (Spec-Dokument).
|
||||
- **Deal-Pipeline** — 5-stufiger Vertriebs-Funnel.
|
||||
- **Stages:** Lead, Erstgespräch, Demo, Angebot, Abschluss.
|
||||
- **Pipeline-Wert** / **Gewichteter Wert** — Summen-KPIs im Vertrieb.
|
||||
- **GTM** / **Vertriebsfortschritt je Produkt** — Go-to-Market-Schrittliste pro Produkt.
|
||||
- **Lead-Funnel** — 5-stufiger Marketing-Funnel.
|
||||
- **MQL** ("Qualifiziert (MQL)") — Marketing-Qualified Lead.
|
||||
- **Reichweite** — Top-of-Funnel-Metrik.
|
||||
- **Übergabe / An Vertrieb übergeben** — Funnel→Pipeline-Transition.
|
||||
- **Kampagne** — Marketing-Aktivität mit Channel und Status.
|
||||
- **Kampagnen-Status:** läuft, geplant, ausgewertet, blockiert, pausiert.
|
||||
- **Gruppen** (Projekt-Gruppen): **Umsatz** vs. **Intern**.
|
||||
- **Crosslink** — Verweis zwischen Bereichen.
|
||||
- **Demodaten** — die SEED-Beispieldaten.
|
||||
- **Produkte (im Prototyp benannt):** **HiveGrid**, **Asset Control**, **Vereins-App**, **Server / Infrastruktur**, **ProdValid**, **PostValid**.
|
||||
|
||||
Hinweis: Im Code-Kommentar erwähnt: *"Das separate Entwicklungs-Cockpit (index.html) bleibt eigenstaendig bestehen; hier ist Entwicklung eine verdichtete Bereichssicht."* — deutet auf ein paralleles, detaillierteres Dev-Cockpit-Tool außerhalb dieses Prototyps hin.
|
||||
|
||||
---
|
||||
|
||||
## Lücken / Unklarheiten / Skelette
|
||||
|
||||
- **Keine echte Auth / Identität.** Es gibt keinen Login. Der Splash zeigt fix "Haris", aber das Formular lässt frei wählen, wer man ist. Keine Sessions, keine Audit-Trails.
|
||||
- **Keine Persistenz auf Server.** Alles im `localStorage` des Browsers; multi-user-uneinsichtbar.
|
||||
- **TODAY ist hart kodiert** (`"2026-05-21"`). Keine echte Zeit, keine Zeitzonen, kein Datepicker.
|
||||
- **Keine Editier-/Löschfunktionen** für Updates, Projekte, Deals, Kampagnen, Meilensteine. Tagesupdates können nur hinzugefügt werden, nicht korrigiert.
|
||||
- **Keine Erstellung neuer Entitäten im UI.** DEV_PROJECTS, DEALS, CAMPAIGNS, FUNNEL, SALES_GTM, TEAM, AREAS sind im JS-Code statisch hinterlegt — kein CRUD-UI.
|
||||
- **Meilensteine ändern sich nicht** über Updates; `done`-Flag ist statisch im Seed-Datensatz.
|
||||
- **Status pro Projekt** wird aus dem **letzten Update** abgeleitet (`devStatus(p)` nimmt `f[0].status`) — keine separate Status-Eingabe pro Projekt.
|
||||
- **Keine echten Visualisierungen** außer Bars und Listen. Keine Library (Chart.js etc.).
|
||||
- **Keine Integration zu Plane** (Tickets) oder **Outline** (Doku) sichtbar — der Prototyp ist standalone.
|
||||
- **Plane/Outline werden nicht referenziert** in der HTML/JS — diese externen Tools sind im Prototyp gar nicht present, obwohl die User-Anfrage sie nennt.
|
||||
- **Keine Kommentar- oder Reaktions-Funktion** auf Updates.
|
||||
- **Keine Notifications** außer dem in-app Toast.
|
||||
- **Keine Suche.**
|
||||
- **Keine Filter, Sortierungen oder Datumsbereiche** auf Feeds.
|
||||
- **Keine Pagination** — Feeds zeigen alle Updates.
|
||||
- **Won-Deals** werden im KPI-Tile gezählt, aber **nirgendwo gelistet/detailliert** (kein Won-Deal-Board).
|
||||
- **"Vereins-App", "ProdValid", "PostValid"** etc. sind als Projekte im Code, aber keine Detail-Pages außerhalb der Standard-Projekt-Detail-Ansicht.
|
||||
- **"Pausiert"-Kampagnen-Status** ist im CSS und im Status-Mapping vorhanden, wird aber von keiner Beispiel-Kampagne genutzt — toter Code-Pfad.
|
||||
- **"WON_DEALS"** Daten sind erfasst, aber nur als Aggregat sichtbar.
|
||||
- **Side-Nudge "Der Rhythmus"** ist reiner Erklärtext — keine konfigurierbaren Rhythmen.
|
||||
- **Mobile/Responsive:** das Layout ist auf 1280 px max-width + viele festgelegte Grid-Spalten ausgelegt; **keine Responsive-Anpassung** im CSS.
|
||||
- **Lokalisierung:** komplett deutsch, hartkodiert (Wochentage, Monate).
|
||||
- **Keine Kommentare/Threads, keine Anhänge, keine Datei-Uploads.**
|
||||
- **Keine Roadmap/Timeline-Visualisierung** (Gantt o. ä.).
|
||||
- **Keine Trend-Daten** über Zeit; alle KPIs sind Snapshots auf TODAY.
|
||||
|
||||
---
|
||||
|
||||
## Sektionen, die unfertig / Platzhalter sind
|
||||
|
||||
Im Sinne von "leere Skelette" gibt es im Prototyp eigentlich keine — alle gerenderten Bereiche sind mit Demo-Daten gefüllt. Die "Lücken" oben sind eher fehlende Konzepte / Features. Vereinzelte tote Code-Pfade:
|
||||
- `cstatus.pausiert` Stil ohne Daten.
|
||||
- `feed-empty` ("Noch keine Updates in diesem Bereich.") und Projekt-Feed-Empty ("Noch keine Updates für dieses Projekt.") nur bei leerem State.
|
||||
- Submit-Fehler-Slot (`#f-err`) leer im Default-Render.
|
||||
+8263
File diff suppressed because it is too large.
Load diff
+1214
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,404 @@
|
||||
---
|
||||
title: PRD – ToolCTRL Unternehmens-Cockpit
|
||||
status: final
|
||||
created: 2026-05-25
|
||||
updated: 2026-05-25
|
||||
owner: Dejancosic
|
||||
audience: ToolCTRL (intern, alle Mitarbeiter)
|
||||
binding_frame: ToolCTRL Projektmanagement-Leitfaden v3.0 (Mai 2026)
|
||||
---
|
||||
|
||||
# PRD – ToolCTRL Unternehmens-Cockpit
|
||||
|
||||
> **Was das ist.** Eine interne Web-Anwendung als tägliches Übersichts-Cockpit über alle laufenden Projekte und Bereiche von ToolCTRL. Sie operationalisiert den **ToolCTRL Projektmanagement-Leitfaden v3.0** als drittes Werkzeug neben **Plane** (Arbeit) und **Outline** (Gedächtnis) — im Leitfaden §5 (Rhythmus) so vorgesehen.
|
||||
>
|
||||
> **Wofür dieses Dokument da ist.** Es legt fest, was das Cockpit im MVP können muss. Capability-orientiert; technische Tiefe lebt im `addendum.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & Problemkontext
|
||||
|
||||
### 1.1 Das echte Problem
|
||||
ToolCTRL hat **kein Tool-Problem, sondern ein Durchziehen-Problem.** Dank KI-gestützter Entwicklung starten wir 10+ Projekte parallel. Die Anfangsphase ist schnell und motivierend. Sobald Reibung entsteht, versandet das Projekt: Updates bleiben aus, Meilensteine schleifen, Verantwortlichkeit löst sich auf, Markt-Releases bleiben aus.
|
||||
|
||||
Wir hatten Jira und Confluence im Einsatz und haben mittendrin damit aufgehört, weil wir „schneller ohne PM" sein wollten. Ergebnis: Wir bekommen nichts fertig. Wir holen die strukturierte Projektarbeit jetzt zurück — mit der **dreigeteilten Werkzeug-Logik des Leitfadens**:
|
||||
|
||||
| Ebene | Werkzeug | Funktion (Leitfaden §5, §8) |
|
||||
|---|---|---|
|
||||
| **Arbeit** | **Plane** | Module → Issues → Sub-Issues, Boards, Cycles. *„Plane ist jederzeit die Wahrheit über den aktuellen Stand."* |
|
||||
| **Gedächtnis** | **Outline** | Projektsteckbrief, Risikoliste, Status-Notizen, Entscheidungen, Lessons Learned. |
|
||||
| **Übersicht / Disziplin** | **Cockpit** (dieses Produkt) | Grober Überblick über alle Projekte, tägliche Updates und Check-ins, illustrierte Unternehmensbereiche. |
|
||||
|
||||
Das Cockpit ist die Antwort auf: *„Warum versanden unsere Projekte? Weil niemand täglich gezwungen ist, sichtbar zu machen, wo er steht — und weil das Leitfaden-Pflicht-Minimum schleichend erodiert."* Das Cockpit erzwingt Sichtbarkeit und macht Erosion früh erkennbar.
|
||||
|
||||
### 1.2 Vision in einem Satz
|
||||
**Das Cockpit ist der Ort, an dem jeder Mitarbeiter in unter 60 Sekunden sieht, woran heute gearbeitet wird, was klemmt, welche Projekte aus dem Plan laufen und welche das Pflicht-Minimum des Leitfadens nicht (mehr) erfüllen — und an dem niemand sich tagelang wegducken kann.**
|
||||
|
||||
### 1.3 Warum jetzt
|
||||
- „Kein PM" als Strategie ist gescheitert.
|
||||
- Der Leitfaden v3.0 setzt die verbindliche Rahmen-Methodik — das Cockpit ist sein operatives Tagesgesicht.
|
||||
- Plane (Tickets) und Outline (Doku) decken den *aggregierten Status* und das *tägliche Sichtbarmachen* nicht ab.
|
||||
- Das Cockpit muss vor der nächsten Welle neuer Projekte stehen, damit wir nicht in dasselbe Muster fallen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ziele & Anti-Ziele
|
||||
|
||||
### 2.1 Ziele (was wir erreichen wollen)
|
||||
- **G1** – Jeder aktive Mitarbeiter schreibt mindestens 1× pro Werktag ein Tagesupdate. Die Quote ist öffentlich sichtbar.
|
||||
- **G2** – Jedes laufende Projekt hat jederzeit einen klar erkennbaren Ampelstatus (Leitfaden §5: Grün / Gelb / Rot) inkl. Trend und einen visuellen Deadline-/Meilenstein-Verlauf.
|
||||
- **G3** – Bereichsübergreifende Blocker sind innerhalb von 24h für alle Beteiligten sichtbar.
|
||||
- **G4** – Das Cockpit ist feste erste Anlaufstation des Jourfixe (Di 11:00) und der täglichen Routine.
|
||||
- **G5** – Mehr Projekte erreichen die „Marktreife / Release"-Phase, weil Stagnation früh sichtbar wird.
|
||||
- **G6** – Erfüllung des **Pflicht-Minimums** (Leitfaden §9) ist pro Projekt sofort sichtbar; nicht erfüllte Punkte sind im Puls aufgelistet.
|
||||
|
||||
### 2.2 Anti-Ziele (was wir explizit NICHT bauen)
|
||||
- **A1** – Kein **Ticketsystem**. Tickets bleiben in Plane.
|
||||
- **A2** – Keine **Doku**. Wissens-Seiten, Steckbriefe, Risikolisten, Lessons Learned bleiben in Outline.
|
||||
- **A3** – **Kein zusätzliches Projektmanagement-Tool** (Leitfaden §8: „Wir führen kein zusätzliches Projektmanagement-Tool ein."). Cockpit hält sich strikt an die Tool-Aufteilung.
|
||||
- **A4** – **Kein Steckbrief-Editor.** Steckbrief lebt in Outline (Leitfaden §3). Das Cockpit verlinkt und referenziert.
|
||||
- **A5** – **Kein gespiegeltes Plane-Board.** Die Issue-Liste bleibt in Plane.
|
||||
- **A6** – **Keine externe Kundenkommunikation** (kein CRM, kein Kundenportal).
|
||||
- **A7** – **Keine Zeiterfassung** — Kimai existiert.
|
||||
- **A8** – **Kein Werkzeug zum Verkauf.** Das Cockpit ist und bleibt rein intern.
|
||||
- **A9** – **Kein konfigurierbares Reporting-/BI-Dashboard** im MVP. Nur die im PRD spezifizierten Aggregate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Erfolgskriterien & Gegenmetriken
|
||||
|
||||
### 3.1 Erfolgsmetriken
|
||||
| ID | Metrik | Zielwert nach 8 Wochen Live-Betrieb |
|
||||
|---|---|---|
|
||||
| M1 | Anteil Werktage mit ≥1 Tagesupdate pro aktiver Person | ≥ 80% |
|
||||
| M2 | Mittlere Zeit von „Blocker eingetragen" bis „im Puls sichtbar" | < 5 Minuten |
|
||||
| M3 | Anteil Projekte mit aktuellem Ampelstatus (Update ≤ 3 Werktage alt) | ≥ 90% |
|
||||
| M4 | Anteil Jourfixes, die das Cockpit als Hauptview nutzen | 100% |
|
||||
| M5 | Aktiv genutzte Bereiche (Dev / Sales / Marketing) bekommen wöchentlich Updates | 3/3 |
|
||||
| M6 | Anteil aktiver Projekte, die alle 6 Leitfaden-§9-Pflicht-Punkte erfüllen | ≥ 90% |
|
||||
|
||||
### 3.2 Gegenmetriken (was wir nicht kaputt machen dürfen)
|
||||
| ID | Gegenmetrik | Grenze |
|
||||
|---|---|---|
|
||||
| CM1 | Gesamt-Tageszeit im Cockpit pro Person (Median, Werktag) | ≤ 3 Minuten |
|
||||
| CM1a | Zeit pro Morgen-Einstempeln + ToDos (Median) | ≤ 60 Sekunden |
|
||||
| CM1b | Zeit pro Abend-Reflexion (Median) | ≤ 90 Sekunden |
|
||||
| CM2 | Anteil inhaltsleerer Pflicht-Updates | qualitativ — Jourfixe-Diskussion |
|
||||
| CM3 | Spannungs-/Lästergefühl durch „fehlt heute"-Anzeige | qualitativ — 4-Wochen-Retro |
|
||||
| CM4 | Doppelpflege Cockpit ↔ Outline-Steckbrief | qualitativ — wenn Steckbrief-Felder im Cockpit dupliziert werden, justieren wir |
|
||||
|
||||
> [NOTE FOR PM] CM2/CM3/CM4 sind qualitativ und werden 4 Wochen nach Launch im Jourfixe reflektiert.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nutzer & Rollen
|
||||
|
||||
### 4.1 Leitfaden-Rollen (§7) – pro Projekt
|
||||
| Rolle | Aufgabe im Cockpit-Kontext |
|
||||
|---|---|
|
||||
| **Projektverantwortliche/r** (P1) | Pflegt Ampelstatus + Trend, achtet auf Pflicht-Minimum, moderiert Cockpit-Sicht im Jourfixe für ihr/sein Projekt. |
|
||||
| **Team** (P2) | Schreibt Tagesupdates mit Projektbezug; setzt Blocker; aktualisiert Meilenstein-Status. |
|
||||
| **Product Owner** (P3, bei Produktprojekten) | Lese-Schwerpunkt; nutzt das Cockpit zur Priorisierungs-Diskussion. |
|
||||
| **Auftraggeber** (P4, intern = Geschäftsführung) | Lesender Hauptnutzer für Pflicht-Minimum und Puls. |
|
||||
|
||||
### 4.2 Querschnittsrollen – alle Mitarbeiter
|
||||
Alle Mitarbeiter sind aktive Cockpit-Nutzer, unabhängig vom Leitfaden-Rolle-Hut:
|
||||
|
||||
| Querschnitt | Hauptnutzen am Cockpit |
|
||||
|---|---|
|
||||
| **Entwickler** (Q1) | Tägliche Updates mit Projektbezug, Meilenstein-Status, eigene Streak. |
|
||||
| **Sales / Vertrieb** (Q2) | Pflegt Deal-Pipeline + GTM-Schritte, postet Vertriebs-Updates. |
|
||||
| **Marketing** (Q3) | Pflegt Lead-Funnel + Kampagnen-Status, postet Marketing-Updates. |
|
||||
| **Design** (Q4) | Postet allgemeine Tagesupdates, kann Projekte als Bezug wählen. |
|
||||
| **Buchhaltung** (Q5) | Postet allgemeine Tagesupdates. |
|
||||
|
||||
Alle Rollen sehen alles (flaches internes Vertrauensmodell im MVP).
|
||||
|
||||
### 4.3 Identität & Authentifizierung
|
||||
Login erfolgt ausschließlich über **Authentik** (auth.tool-ctrl.de) per OIDC. Default-Bereich wird aus Authentik-Group-Claim abgeleitet (Mapping in OQ-1). Projektverantwortliche/r wird pro Projekt im Cockpit als Attribut gesetzt.
|
||||
|
||||
---
|
||||
|
||||
## 5. User Journeys
|
||||
|
||||
### UJ-1 — Tagesrhythmus: Einstempeln, Arbeiten, Reflektieren
|
||||
*Primär: Q1-Q5*
|
||||
|
||||
**Morgens — Einstempeln + ToDos**
|
||||
1. Person öffnet das Cockpit, klickt „Einstempeln".
|
||||
2. Trägt eine **ToDo-Liste für heute** ein: 1-N Items, jedes Item mit Titel und optionalem Bezug (Projekt/Deal/Kampagne/Allgemein).
|
||||
3. Speichert → Person verschwindet aus „heute fehlt eingestempelt", gilt für die Streak als „heute begonnen".
|
||||
|
||||
**Abends — Reflexion**
|
||||
4. Person ruft im Lauf des Tages oder spätestens abends ihre ToDo-Liste auf.
|
||||
5. Hakt ab, was erledigt wurde.
|
||||
6. Trägt im Freitext nach, was sonst noch passiert ist (max. 1.000 Zeichen).
|
||||
7. Setzt **Ampel** und — falls Bezug=Projekt vorhanden — **Trend**.
|
||||
8. Optional: Blocker → bereichsübergreifende Blocker-Liste im Puls.
|
||||
9. Speichert → Tag gilt als „voll abgeschlossen", Streak+1.
|
||||
|
||||
**Nächster Morgen — Kollegen-Sichtbarkeit**
|
||||
10. Ab dem nächsten Werktagsmorgen ist der konsolidierte Vortags-Eintrag (ToDos + Häkchen + Reflexion + Ampel + Trend) für alle Kollegen sichtbar.
|
||||
11. Innerhalb des laufenden Tages sehen Kollegen nur die binäre Anwesenheit („eingestempelt: ja/nein"), nicht die ToDo-Inhalte. Reduziert In-Day-Surveillance.
|
||||
|
||||
### UJ-2 — Jourfixe-Moderation
|
||||
*Primär: P1, P4 (dienstags 11:00)*
|
||||
1. PM öffnet Cockpit-Puls.
|
||||
2. Geht die 3 Bereiche durch — Ampel pro Bereich, Top-Projekt-Status, offene Blocker, **Pflicht-Minimum-Score**.
|
||||
3. Klickt auf rote/gelbe Projekte → Detail-View mit letzten Updates, Ampel + Trend, Meilenstein-Status, Deadline-Bar.
|
||||
4. Diskutiert Blocker mit Verantwortlichen.
|
||||
|
||||
### UJ-3 — Projekt-Verfall früh erkennen
|
||||
*Primär: P1, P4*
|
||||
1. Person scrollt durch den Puls.
|
||||
2. Sieht in einem Bereich ein Projekt mit roter Ampel, ablaufender Deadline-Bar **oder** unerfülltem Pflicht-Minimum.
|
||||
3. Klickt → Detail-View zeigt: letztes Update vor X Tagen, X von Y Meilensteinen fällig, welche Pflicht-Punkte offen sind.
|
||||
4. Schreibt direkt einen Kommentar/eine Nachfrage als allgemeines Update mit Bezug=Projekt.
|
||||
|
||||
### UJ-4 — Onboarding eines neuen Mitarbeiters
|
||||
*Primär: Q1-Q5*
|
||||
1. Mitarbeiter wird in Authentik angelegt → Cockpit-Zugriff automatisch via OIDC.
|
||||
2. Erster Login: leerer „Mein Status"-Bereich + Hinweis „Hier schreibst du dein erstes Tagesupdate".
|
||||
3. Default-Bereich aus Authentik-Group.
|
||||
4. Schreibt das erste Update, erscheint ab sofort in Anwesenheits-/Streak-Liste.
|
||||
|
||||
### UJ-5 — Projekt anlegen (Cockpit-Sicht)
|
||||
*Primär: P1*
|
||||
1. Person legt in Outline den Steckbrief an (Leitfaden §3).
|
||||
2. Wechselt ins Cockpit → „Neues Projekt anlegen" — füllt Cockpit-Sichtdaten: Name, Verantwortliche/r, Projektart (Kundenprojekt/Internes Projekt), Arbeitsmodus (Cycle/Kanban), **Steckbrief-URL** (Outline), **Plane-Board-URL**, grobe Meilensteine.
|
||||
3. Cockpit zeigt das neue Projekt in seinem Bereich und im Puls. Die Pflicht-Minimum-Häkchen werden initial bewertet (Steckbrief vorhanden ✓, Verantwortliche/r ✓ usw.).
|
||||
|
||||
> [ASSUMPTION] Authentik-Groups werden für die initiale Bereichszuweisung genutzt. Mapping legen wir in der Architektur-Phase fest.
|
||||
|
||||
---
|
||||
|
||||
## 6. Funktionale Anforderungen (Features)
|
||||
|
||||
> Feature-Gruppen mit global durchnummerierten FR-IDs. Stabil für Architektur- und Story-Phase.
|
||||
|
||||
### 6.1 Feature-Gruppe A: Unternehmens-Puls (Startseite)
|
||||
**Zweck:** Eine Seite, auf der jeder in unter 60s sieht, was los ist.
|
||||
|
||||
- **FR-1** Übersichtsleiste aller Bereiche (MVP: Entwicklung, Vertrieb, Marketing) inkl. aggregiertem Ampelstatus und Anzahl heute-fehlender Updates pro Bereich.
|
||||
- **FR-1a** **Ampel-Aggregation pro Bereich:** *Rot*, wenn ≥1 Projekt rot ist; *Gelb*, wenn ≥1 Projekt gelb und kein rotes; *Grün*, wenn alle Projekte grün; *Grau*, wenn keine Projekte aktiv. *Pflicht-Minimum-Verstöße eines Projekts heben die Ampel mindestens auf Gelb.*
|
||||
- **FR-2** Bereichsübergreifende Liste aller offenen Blocker (sortiert nach Alter).
|
||||
- **FR-3** Heute-Quote: „X von Y Mitarbeitern haben heute gepingt" mit Klick → Liste der fehlenden Personen.
|
||||
- **FR-4** Anzeige des nächsten Jourfixe-Termins (statisch konfigurierbar; Default Di 11:00).
|
||||
- **FR-5** Pro Bereich werden folgende Key-Metrics angezeigt (verbindlicher Katalog für MVP):
|
||||
- **Entwicklung:** Anzahl Projekte gesamt | im Plan (grün) | gefährdet (gelb) | blockiert (rot) | überzogene Meilensteine
|
||||
- **Vertrieb:** Pipeline-Wert gewichtet (Σ value × probability) | offene Deals | gewonnene Deals (Quartal, # + EUR) | offene GTM-Schritte
|
||||
- **Marketing:** Reichweite (laufende Periode) | aktive Kampagnen | Leads (Periode) | MQLs (Periode) | Übergaben an Vertrieb (Periode)
|
||||
- **FR-6** Klick auf Bereichs-Kachel öffnet die Bereichs-Detail-View.
|
||||
- **FR-7** **Pflicht-Minimum-Übersicht** im Puls: Liste aller aktiven Projekte mit roten/gelben Pflicht-Minimum-Punkten (siehe FR-38).
|
||||
|
||||
### 6.2 Feature-Gruppe B: Bereichs-Detail Entwicklung
|
||||
- **FR-8** Liste aller aktiven Projekte der Entwicklung, gruppiert nach **Projektart** (Kundenprojekt / Internes Projekt — Leitfaden §1).
|
||||
- **FR-9** Pro Projekt-Kachel: Name, Verantwortliche/r (P1), Ampelstatus (Grün/Gelb/Rot) + Trend (↗︎ besser / → gleich / ↘︎ schlechter), Deadline-Progress-Bar (verstrichene Zeit / Gesamtzeit), Anzahl offener Meilensteine, Pflicht-Minimum-Score (z. B. „5/6").
|
||||
- **FR-10** Klick → Projekt-Detail mit:
|
||||
- Kopfzeile: Name, Verantwortliche/r, Projektart, Arbeitsmodus (Cycle/Kanban), Ampel + Trend
|
||||
- Pflicht-Minimum-Anzeige (FR-38)
|
||||
- **Verlinkungen (Pflicht-Felder):** Outline-Steckbrief-URL, Plane-Board-URL, optional Risikoliste-URL
|
||||
- Optional Definition of Done (Freitext, Cockpit-Sichtfeld)
|
||||
- Meilenstein-Checkliste (Cockpit-Sicht — siehe FR-11)
|
||||
- Update-Feed des Projekts
|
||||
- **FR-11** Meilensteine werden im Cockpit als grobe Sicht gepflegt (Titel, Fälligkeit, Status: offen/erledigt/überzogen). *Wahrheit ist der Steckbrief in Outline;* Cockpit-Meilensteine sind eine reduzierte Sichtkopie. Doppelpflege wird akzeptiert, bis Phase-2-API-Sync eingeführt wird.
|
||||
|
||||
### 6.3 Feature-Gruppe C: Bereichs-Detail Vertrieb
|
||||
- **FR-12** 5-Spalten-Kanban: Lead → Erstgespräch → Demo → Angebot → Abschluss. Drag-and-Drop zwischen Spalten.
|
||||
- **FR-13** Deal-Karten zeigen Name, Verantwortlicher, Wert (€), **Stage-Wahrscheinlichkeit (probability, 0-100%)**, Tage in aktueller Stage.
|
||||
- **FR-14** **Vertriebs-Header-KPIs:** Pipeline-Wert gewichtet (Σ value × probability), gewonnene Deals (# + EUR, laufendes Quartal), aktiver Deal-Count, durchschnittliche Stage-Verweildauer.
|
||||
- **FR-15** Pro Produkt eine sortierbare GTM-Schritt-Liste (Schritt, Verantwortlicher, Status).
|
||||
- **FR-16** Vertrieb-Update-Feed (Filter auf Bezug=Deal oder Bezug=Vertrieb-allgemein).
|
||||
|
||||
### 6.4 Feature-Gruppe D: Bereichs-Detail Marketing
|
||||
- **FR-17** Horizontaler Lead-Funnel mit 5 Stufen (Reichweite → Website → Leads → MQL → Übergabe) und Conversion-Prozenten zwischen den Stufen.
|
||||
- **FR-18** Liste aktiver Kampagnen mit Status (Aktiv / Pausiert / Beendet), Reichweite, Verantwortlicher.
|
||||
- **FR-19** **Crosslink-Banner Marketing↔Vertrieb:** Sichtbarer Banner in Marketing & Vertrieb: „Marketing hat X Leads übergeben in [Zeitraum], Vertrieb hat daraus Y Deals erzeugt (Z €)."
|
||||
- **FR-20** Marketing-Update-Feed (Filter auf Bezug=Kampagne oder Bezug=Marketing-allgemein).
|
||||
|
||||
### 6.5 Feature-Gruppe E: Tagesrhythmus (Einstempeln · ToDos · Reflexion)
|
||||
|
||||
> Ein „Tagesupdate" besteht aus zwei Touchpoints am gleichen Tag: **Morgen-Einstempeln mit ToDo-Liste** und **Abend-Reflexion**. Beides zusammen wird am nächsten Werktagsmorgen als konsolidierter Vortags-Eintrag für Kollegen sichtbar.
|
||||
|
||||
#### 6.5.1 Morgen — Einstempeln & ToDos
|
||||
- **FR-21** „Einstempeln"-Aktion auf der Puls-Seite (großer Button bei nicht eingestempelten Personen): öffnet die ToDo-Eingabe für heute.
|
||||
- **FR-22** **ToDo-Liste** für den aktuellen Werktag:
|
||||
- 1-N ToDo-Items
|
||||
- pro Item: Titel (max. 200 Zeichen), optionaler Bezug (Projekt/Deal/Kampagne/Allgemein), optionale Bezugs-ID
|
||||
- keine Ampel/Trend pro ToDo
|
||||
- **FR-23** Beim Einstempeln gilt der Tag als „begonnen" — Person verschwindet aus der „heute fehlt"-Liste auf dem Puls.
|
||||
- **FR-24** **Sichtbarkeit innerhalb des laufenden Tages:**
|
||||
- Kollegen sehen nur „Person hat sich eingestempelt (ja/nein)" — keine ToDo-Inhalte.
|
||||
- Die Person selbst sieht ihre ToDo-Liste in „Mein heute".
|
||||
- Projektverantwortliche/r eines bezogenen Projekts darf die ToDos sehen, falls Bezug auf das Projekt verweist (operative Notwendigkeit).
|
||||
- **FR-25** ToDos sind während des Tages bis zur Reflexion editierbar (hinzufügen / löschen / ändern).
|
||||
|
||||
#### 6.5.2 Abend — Reflexion
|
||||
- **FR-26** **Reflexions-Formular**: Person ruft ihre ToDo-Liste auf und füllt aus:
|
||||
- Pro ToDo: ☑️ erledigt / ☐ offen / ✖️ verworfen (mit optionalem Grund)
|
||||
- **Reflexions-Freitext** (max. 1.000 Zeichen) — was sonst noch passiert ist
|
||||
- **Status-Ampel** (Leitfaden §5 Wortlaut):
|
||||
- 🟢 Grün – im Plan
|
||||
- 🟡 Gelb – spürbare Abweichung, **aber** Maßnahmenplan greift
|
||||
- 🔴 Rot – Abweichung **ohne** gesicherten Plan, braucht jetzt eine Entscheidung
|
||||
- **Trend** (Pflicht bei Bezug=Projekt; sonst optional): ↗︎ besser / → gleich / ↘︎ schlechter
|
||||
- **Blocker** (optional Freitext, erzeugt Blocker-Eintrag — siehe FR-41ff.)
|
||||
- **URL** (optional, z. B. Plane-Ticket oder Outline-Seite)
|
||||
- **FR-27** Nach Speichern der Reflexion ist der Tag „voll abgeschlossen". Person zählt für die Streak als voller Tag.
|
||||
- **FR-28** Reflexion ist nach 30 Minuten nicht mehr editierbar. Erinnerung optional („Du hast heute noch nicht reflektiert"-Hinweis ab z. B. 17:00 Uhr — keine externe Push-Notification im MVP).
|
||||
|
||||
#### 6.5.3 Konsolidierung & Sichtbarkeit
|
||||
- **FR-29** Am nächsten Werktagsmorgen wird der konsolidierte Vortags-Eintrag publiziert: ToDos + Häkchen + Reflexions-Freitext + Ampel + Trend werden für Kollegen sichtbar im Bereichs-Feed und im jeweiligen Bezugs-Feed.
|
||||
- **FR-30** Default-Sortierung in Feeds: neueste oben. Pagination: 50 Items pro Seite oder „Mehr laden".
|
||||
- **FR-31** Eine Reflexion setzt Ampelfarbe und Trend des verlinkten Projekts/Deals/Kampagne — zuletzt eingetragener Wert gewinnt (mehrere Bezüge möglich → jeder Bezug erhält die Werte).
|
||||
- **FR-32** **Rückwirkende Erfassung:** Bis zu **einem Werktag zurück** dürfen Einstempeln + Reflexion nachgetragen werden. Ältere Tage sind nicht mehr erfassbar (außer als „Abwesend" markiert, siehe FR-37).
|
||||
- **FR-33** **Tages-Status pro Person** (sichtbar im eigenen Profil und im Puls für andere):
|
||||
- 🟢 voll (Einstempeln + Reflexion) — zählt für Streak
|
||||
- 🟡 halb (nur Einstempeln, keine Reflexion) — zählt nicht für Streak, aber Person ist „nicht durchgefallen"
|
||||
- ⚪ nicht angefangen (kein Einstempeln) — fällt aus Streak
|
||||
- 🟣 abwesend
|
||||
|
||||
### 6.6 Feature-Gruppe F: Anwesenheit / Update-Serie (Streak) / Disziplin
|
||||
- **FR-34** Pro Person wird die aktuelle **Update-Serie** (Streak) — aufeinanderfolgende Werktage mit Tages-Status „voll" — angezeigt. „Halbe" Tage brechen die Streak nicht, zählen aber nicht zur Serie.
|
||||
- **FR-35** „Heute fehlt"-Liste auf der Puls-Seite (siehe FR-3); klickbar, zeigt Personen ohne Einstempeln am aktuellen Werktag nach 11:00 und Personen mit „halbem" Tag-Status nach 17:00.
|
||||
- **FR-36** Werktage-Definition: Mo-Fr (Feiertage Phase 2).
|
||||
- **FR-37** Eine Person kann sich für einen Tag (oder Zeitraum) als „Abwesend" markieren (Urlaub / Krankheit). Diese Tage zählen nicht gegen die Streak.
|
||||
|
||||
### 6.7 Feature-Gruppe G: **Pflicht-Minimum-Check (Leitfaden §9)**
|
||||
- **FR-38** Pro aktivem Projekt zeigt das Cockpit die 6 Pflicht-Punkte des Leitfadens als sichtbare Checkliste:
|
||||
1. Projektsteckbrief in Outline freigegeben → erfüllt, wenn `outline_steckbrief_url` gepflegt **und** als „freigegeben" markiert ist
|
||||
2. Projektverantwortliche/r gesetzt → erfüllt, wenn P1 zugewiesen
|
||||
3. Plane-Board mit zerlegter Arbeit → erfüllt, wenn `plane_project_url` gepflegt
|
||||
4. Meilensteine mit groben Terminen → erfüllt, wenn ≥1 Meilenstein definiert
|
||||
5. Status-Ampel aktuell → erfüllt, wenn letzte Reflexion mit Bezug=Projekt ≤ 7 Werktage alt
|
||||
6. Abschluss-Retrospektive — *nur bei beendeten Projekten relevant; aktiv = n/a*
|
||||
- **FR-39** Im MVP werden Pflicht-Punkte 1, 2, 3, 4 als manuelle Bestätigungs-Felder pro Projekt geführt (Cockpit-Sicht, Doppelpflege akzeptiert). Punkt 5 wird automatisch aus dem Reflexions-Zeitstempel abgeleitet. Punkt 6 wird beim Abschluss-Workflow gesetzt.
|
||||
- **FR-40** Projekte mit ≥1 unerfülltem Pflicht-Punkt erscheinen prominent auf der Puls-Seite (siehe FR-7).
|
||||
|
||||
### 6.8 Feature-Gruppe H: Blocker
|
||||
- **FR-41** Ein Blocker entsteht entweder als Feld in einer Reflexion oder separat über „Neuer Blocker"-Button.
|
||||
- **FR-42** Blocker tragen: Titel, Beschreibung, Verantwortlicher (zur Lösung), Ersteller, Bereich(e), Datum offen, Status (offen / gelöst).
|
||||
- **FR-43** Blocker erscheinen auf der Puls-Seite, in den betroffenen Bereichs-Detail-Views und in der Projekt-Detail-View (falls projektgebunden).
|
||||
- **FR-44** Auflösung: Auflöser, Auflöse-Datum und Auflösungs-Kommentar werden persistiert. **Nur die/der Projektverantwortliche (P1) des bezogenen Projekts darf einen Blocker als gelöst markieren.** Bei Blockern ohne Projektbezug darf die/der Ersteller/in oder Verantwortliche/r des Blockers selbst auflösen.
|
||||
|
||||
### 6.9 Feature-Gruppe I: Projekt-Abschluss
|
||||
- **FR-45** Projektverantwortliche/r kann ein Projekt im Cockpit auf „abgeschlossen" setzen.
|
||||
- **FR-46** Beim Abschluss prüft das Cockpit Pflicht-Minimum-Punkt 6 (Lessons-Learned-URL) — wenn fehlend, fordert es die URL an.
|
||||
- **FR-47** Abgeschlossene Projekte verschwinden aus dem Puls, bleiben aber in einer „Archiv"-Sicht pro Bereich lesbar.
|
||||
|
||||
### 6.10 Feature-Gruppe J: Navigation, Suche & Filter
|
||||
- **FR-48** Globale Top-Navigation: Puls / Entwicklung / Vertrieb / Marketing / „Mein heute" / Eigenes Profil / Archiv.
|
||||
- **FR-49** Volltextsuche über Updates → Phase 2. Im MVP genügt Filterung im Feed nach Bereich/Bezug.
|
||||
|
||||
---
|
||||
|
||||
## 7. Nicht-funktionale Anforderungen (NFRs)
|
||||
|
||||
### 7.1 Performance
|
||||
- **NFR-1** Puls-Seite lädt vollständig in < 1,5 s bei 6-15 aktiven Nutzern und 90 Tagen Historie.
|
||||
- **NFR-2** Tagesupdate-Speichern dauert < 500 ms (Server-Roundtrip).
|
||||
|
||||
### 7.2 Verfügbarkeit
|
||||
- **NFR-3** Zielverfügbarkeit Mo-Fr 8-19 Uhr: 99% (kein 24/7-SLA).
|
||||
- **NFR-4** Geplante Wartungsfenster außerhalb der Kernzeit kommunizierbar.
|
||||
|
||||
### 7.3 Sicherheit & Privacy
|
||||
- **NFR-5** Alle Routen sind hinter Authentik-OIDC abgesichert; kein anonymer Zugriff.
|
||||
- **NFR-6** Keine PII von Externen (Deal-Karten enthalten nur intern bekannte Firmennamen, keine Klar-Personendaten).
|
||||
- **NFR-7** Audit-Spur: jedes mutierende Event (Update, Blocker, Meilenstein-Statuswechsel, Pflicht-Minimum-Änderung) hat `actor`, `timestamp`, `payload`.
|
||||
|
||||
### 7.4 Bedienbarkeit & Visual
|
||||
- **NFR-8** Brand-konform: primär weiß, sekundär schwarz, gemäß ToolCTRL Brand Identity. Konkrete Design-Tokens in der UX-Phase.
|
||||
- **NFR-9** Tagesupdate-Schreib-Workflow ist in ≤ 3 Klicks ab Puls erreichbar.
|
||||
- **NFR-10** Desktop-first; mobile-Responsiveness ist Phase 2 (mobil-lesbar ist Pflicht, mobil-schreibbar nicht).
|
||||
- **NFR-11** Sprache: Deutsch durchgängig im UI. Keine i18n im MVP.
|
||||
|
||||
### 7.5 Voice & Tone (Mikrocopy)
|
||||
- **NFR-12** Tonalität ist **locker, direkt, deutsch, kurze Imperative.** Beispiele aus dem Prototyp als Voice-Anker:
|
||||
- „Stark."
|
||||
- „Wie läuft es?"
|
||||
- „Du hast heute noch nicht gepingt."
|
||||
- „Willkommen, [Name]"
|
||||
- **NFR-13** Keine Corporate-Sprech-Phrasen („wir möchten Sie darauf hinweisen", „im Zuge der Optimierung"). Keine englischen Floskeln im Fließtext der UI (Fachbegriffe wie „Backlog", „Kanban" sind okay — sie kommen aus dem Leitfaden).
|
||||
|
||||
### 7.6 Datenhaltung
|
||||
- **NFR-14** Persistenz in **Postgres**. Kein localStorage-only.
|
||||
- **NFR-15** Tägliches Backup (Mechanik in Architektur-Phase).
|
||||
|
||||
### 7.7 Erweiterbarkeit
|
||||
- **NFR-16** Datenmodell muss Phase-2-Erweiterungen ohne mehr als eine zweistündige Migration tragen: zusätzliche Bereiche (Design, Buchhaltung), Plane/Outline-API-Sync, Kommentare auf Updates, Mobile, Feiertags-Kalender.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of Scope (MVP) / Phase-2-Kandidaten
|
||||
|
||||
| Item | Begründung |
|
||||
|---|---|
|
||||
| Plane-API-Sync (Ticket-Status, Owner, Titel ziehen; Meilensteine auto-erkennen) | Komplexität verzögert MVP; URL-Link reicht zunächst. |
|
||||
| Outline-API-Sync (Steckbrief-Status „freigegeben" auto-erkennen) | Selbe Begründung; manuelle Bestätigung reicht. |
|
||||
| Outline-Embed | Verlinkung genügt. |
|
||||
| Design-Bereich, Buchhaltung-Bereich (eigene Detail-Views) | Beide nutzen „Allgemein" im MVP. |
|
||||
| Mobile-App / Responsive Mobile-UI (schreibend) | Desktop deckt 95% der täglichen Nutzung ab; mobil bleibt lesbar. |
|
||||
| Volltextsuche über Updates | Filter im Feed reicht im MVP. |
|
||||
| Reaktionen / Kommentare auf Updates | Nice-to-have, kein Kernproblem. |
|
||||
| Reminders per Element/Matrix/Mail bei fehlendem Update | Cockpit-Visualisierung zuerst beweisen, dann pushen. |
|
||||
| Konfigurierbare Reporting-/BI-Dashboards | Andere Werkzeuge geeigneter. |
|
||||
| Zeiterfassung | Kimai existiert. |
|
||||
| Mehrsprachigkeit | Nicht nötig. |
|
||||
| Feiertags-Kalender (für Streak-Berechnung) | MVP: Mo-Fr ohne Feiertags-Sonderfall. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Offene Fragen
|
||||
|
||||
| ID | Frage | Wer entscheidet | Bis wann | Phase-Blocker? |
|
||||
|---|---|---|---|---|
|
||||
| OQ-1 | Genaues Mapping Authentik-Groups → Cockpit-Bereiche? | Architektur-Phase | Vor MVP-Bau | ja |
|
||||
| OQ-2 | Welche konkreten Brand-Tokens (Hex, Typografie, Logo-Verwendung) gelten 1:1? | UX-Phase | Vor UI-Bau | ja |
|
||||
| OQ-5 | Archivierte Projekte: Historie für immer behalten oder nach X Monaten löschen? | Architektur + Product | Vor MVP-Bau | nein |
|
||||
| OQ-8 | Erinnerungszeit für „du hast noch nicht reflektiert"-Hinweis (Default 17:00)? | Product (Dejan) | Vor Story-Bau | nein |
|
||||
| OQ-9 | Was passiert, wenn eine Person die ToDo-Liste morgens leer lässt — ist „eingestempelt mit 0 ToDos" gültig oder muss ≥1 ToDo gesetzt sein? PRD-Default: ≥1 ToDo Pflicht. | Product (Dejan) | Vor MVP-Bau | nein |
|
||||
| OQ-10 | Darf eine Person ToDos *nach* dem Abend-Reflektieren noch nachtragen (z. B. „bin um 10 noch zur Apotheke gefahren")? PRD-Default: nein, nach Reflexion ist der Tag geschlossen. | Product (Dejan) | Vor Story-Bau | nein |
|
||||
|
||||
**Beschlossene OQs (vorher offen, jetzt im PRD verankert):**
|
||||
- ~~OQ-3~~ → Rückwirkende Erfassung **max 1 Werktag zurück** (FR-32).
|
||||
- ~~OQ-4~~ → Nur P1 (oder Blocker-Ersteller/Verantwortlicher bei projektlosen Blockern) darf auflösen (FR-44).
|
||||
- ~~OQ-6~~ → Pflicht-Minimum-Punkt 1 als manuelle Cockpit-Checkbox im MVP (FR-39).
|
||||
- ~~OQ-7~~ → Trend **Pflicht bei Bezug=Projekt**, sonst optional (FR-26).
|
||||
|
||||
---
|
||||
|
||||
## 10. Annahmen-Index
|
||||
|
||||
Sammlung aller im PRD getroffenen Annahmen — zur expliziten Bestätigung im Architektur- bzw. UX-Lauf:
|
||||
|
||||
- **A-1** Authentik-OIDC-Integration ist technisch in < 1 Tag erledigt (Erfahrung aus Plane/Outline-Setup).
|
||||
- **A-2** Authentik-Groups werden für die initiale Bereichszuweisung genutzt; Mapping wird in der Architektur-Phase festgelegt.
|
||||
- **A-3** Team wächst in den nächsten 12 Monaten nicht über 30 Personen.
|
||||
- **A-4** Postgres + ein leichter Backend-Layer reichen aus; konkrete Backend-Wahl in Architektur-Phase.
|
||||
- **A-5** Pflicht-Minimum-Punkte 1-4 werden im MVP manuell im Cockpit bestätigt (kein Outline-/Plane-API-Sync).
|
||||
- **A-6** Doppelpflege Cockpit-Meilenstein ↔ Outline-Steckbrief ist im MVP akzeptiert; wird beobachtet (CM4).
|
||||
|
||||
---
|
||||
|
||||
## 11. Risiken
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|---|---|
|
||||
| Das Cockpit wird selbst zu einem „starten und abbrechen"-Projekt. | MVP-Scope hart klein halten. Erstes Release in 4 Wochen anpeilen. Selbst-Dogfooding ab Tag 1. **Das Cockpit selbst muss das Pflicht-Minimum erfüllen.** |
|
||||
| Daily-Update-Pflicht erzeugt Lästergefühl. | CM2/CM3 messen; 4-Wochen-Retro; Mechanik justierbar. |
|
||||
| Cockpit erodiert die Tool-Aufteilung des Leitfadens (wird Schatten-Plane oder Schatten-Outline). | A1-A5 + CM4 wachen darüber. Architektur-Phase prüft den Schnitt explizit. |
|
||||
| Sales-Daten sind sensibel und sollen evtl. nicht jeder sehen. | MVP: flaches Vertrauen, alle sehen alles. Bei Bedarf in Phase 2 Rollen-Sichtbarkeit. |
|
||||
| Pflicht-Minimum-Häkchen werden „durchgewinkt" und verlieren Bedeutung. | Jourfixe diskutiert Stichproben. Phase 2: Outline-/Plane-API-Validierung. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Erfolgskontrolle & Review-Punkte
|
||||
|
||||
- **Woche 2 nach Launch** – kurzer Puls-Check im Jourfixe: M1 erreicht?
|
||||
- **Woche 4 nach Launch** – Retro: CM2/CM3/CM4. Adjust falls nötig.
|
||||
- **Woche 8 nach Launch** – Vollauswertung gegen M1-M6. Entscheidung: Phase 2 starten oder MVP weiter härten?
|
||||
|
||||
---
|
||||
|
||||
*Ende PRD. Begleitend: `addendum.md` (technische Tiefe, verworfene Alternativen, Leitfaden-Bezüge, In-Depth-Personas) und `.decision-log.md` (Audit-Trail).*
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
# Reconciliation: HTML-Prototyp ↔ PRD + Addendum
|
||||
|
||||
Quelle (Prototyp): `inputs/unternehmens-cockpit.html` (verdichtet in `extract-prototype-html.md`)
|
||||
Geprüft gegen: `prd.md` + `addendum.md`
|
||||
Datum: 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## Vollständig abgedeckt
|
||||
|
||||
### Hauptstruktur & Navigation
|
||||
- **Cockpit als Default-View** → PRD §1.1 (Tabelle), §6.1 (Feature-Gruppe A: Puls als Startseite), FR-32 (Top-Nav inkl. "Puls")
|
||||
- **Tagesupdate als zweite Top-Level-View** → FR-32 ("Neues Tagesupdate" in Top-Nav), FR-19 (Formular), UJ-1
|
||||
- **Drei Areas: Entwicklung / Vertrieb / Marketing** → PRD §6.1 (FR-1), §6.2/§6.3/§6.4 (eigene Feature-Gruppen), Addendum §6 (Vokabular)
|
||||
- **Live-Datum im Header** → implizit via "Heute"-Logik (FR-3, FR-25)
|
||||
|
||||
### Unternehmens-Puls
|
||||
- **Aggregierter Ampelstatus pro Area** → FR-1
|
||||
- **Bereichsübergreifend offene Blocker** → FR-2, FR-30, Risiko-Modell §10
|
||||
- **Heute-Quote ("X von Y haben gepingt")** → FR-3
|
||||
- **Jourfixe-Hinweis (Di 11:00)** → FR-4, Vision G4, Addendum §6
|
||||
- **Key-Metrics pro Area-Tile (3-5 Metrics)** → FR-5
|
||||
- **Klick auf Area-Tile öffnet Detail-View** → FR-6
|
||||
|
||||
### Entwicklung
|
||||
- **Projekt-Liste mit Gruppierung Umsatz/Intern** → FR-7
|
||||
- **Status-Pill je Projekt (abgeleitet aus letztem Update)** → FR-8, FR-23
|
||||
- **Deadline/Meilenstein-Progress** → FR-8, FR-10
|
||||
- **Projekt-Detail mit Meilenstein-Checkliste + Update-Feed** → FR-9
|
||||
- **Meilensteine mit Titel/Fälligkeit/Status** → FR-10
|
||||
- **CRUD für Projekte/Meilensteine** → FR-11 (Prototyp hatte nur Read; PRD erweitert bewusst)
|
||||
|
||||
### Vertrieb
|
||||
- **5-Spalten Kanban (Lead → Erstgespräch → Demo → Angebot → Abschluss)** → FR-12, Addendum §6 (Deal-Stage)
|
||||
- **Deal-Karten mit Name/Owner/Wert** → FR-13
|
||||
- **GTM-Schritt-Liste pro Produkt** → FR-14, Addendum §6
|
||||
- **Vertrieb-Update-Feed** → FR-15
|
||||
- **Crosslink zu Marketing (Lead-Übergabe)** → implizit über FR-30 (Blocker bereichsübergreifend) + UJ-2, allerdings nicht prominent (siehe Gaps)
|
||||
|
||||
### Marketing
|
||||
- **Lead-Funnel mit 5 Stufen + Conversion-Raten** → FR-16, Addendum §6
|
||||
- **Kampagnen-Liste mit Status** → FR-17
|
||||
- **Marketing-Update-Feed** → FR-18
|
||||
|
||||
### Tagesupdates
|
||||
- **Formular mit Person/Bereich/Bezug/Freitext/Ampel/Blocker** → FR-19
|
||||
- **Bezug = Projekt/Deal/Kampagne/Allgemein** → FR-19, Addendum §6
|
||||
- **Chronologische Anzeige in Area- und Bezug-Feed** → FR-21
|
||||
- **Update setzt Ampelfarbe des Referenzobjekts** → FR-23
|
||||
- **Optionale URL als Zusatz-Bezug** → FR-22 (Erweiterung gegenüber Prototyp)
|
||||
- **Edit-Fenster (Prototyp: kein Edit)** → FR-20 (PRD lockert bewusst auf 30min)
|
||||
- **Validierung (Pflichtfelder)** → implizit in FR-19
|
||||
|
||||
### Disziplin / Streak
|
||||
- **Streak-Counter pro Person/Projekt** → FR-24, Vokabular im Addendum
|
||||
- **"Heute fehlt"-Liste / Side-Nudge** → FR-3, FR-25
|
||||
- **Werktage-Definition** → FR-26
|
||||
- **Abwesenheit / Urlaub-Marker** → FR-27 (Prototyp hatte das nicht — PRD erweitert)
|
||||
|
||||
### Blocker
|
||||
- **Blocker als Feld in Update** → FR-28
|
||||
- **Bereichsübergreifend sichtbar** → FR-2, FR-30
|
||||
- **Standalone-Blocker-Erstellung** → FR-28 (PRD erweitert gegenüber Prototyp)
|
||||
- **Blocker mit Owner/Status/Auflösung** → FR-29, FR-31
|
||||
|
||||
### Status-System (Ampel)
|
||||
- **3 Status: Im Plan / Gefährdet / Blockiert** → FR-19, Addendum §6
|
||||
- **green/amber/red Mapping** → Datenmodell-Skizze in Addendum §1.2
|
||||
- **Status-Rollup auf Area** → FR-1 (aggregierter Status)
|
||||
|
||||
### Identität
|
||||
- **Personen mit Name/Rolle** → PRD §4.1 (Rollen-Tabelle), Datenmodell User in Addendum §1.2
|
||||
- **OIDC-Auth (Prototyp hatte keine Auth — bewusste Erweiterung)** → PRD §4.2, NFR-5, Addendum §1.3
|
||||
|
||||
### Datenmodell (impliziert)
|
||||
- **TEAM/AREA/PROJECT/DEAL/CAMPAIGN/UPDATE/MILESTONE/STAGE/FUNNEL/GTM** → Addendum §1.2 mit allen wesentlichen Entitäten
|
||||
|
||||
---
|
||||
|
||||
## Bewusst weggelassen (mit Begründung im PRD)
|
||||
|
||||
- **localStorage-only Persistenz** → Addendum §2.5 (explizit verworfen, Postgres stattdessen, NFR-12)
|
||||
- **Single-User-Modell ohne Auth** → PRD §4.2 (Authentik-OIDC), Addendum §2.5
|
||||
- **Intro/Splash-Screen ("Willkommen, Haris")** → nicht in MVP-Scope; nicht explizit als Anti-Ziel formuliert, aber als kosmetische Prototypen-Animation klar nicht produktiv (vgl. Addendum §5: Prototyp = Logik-Referenz, kein Visual-Referenz)
|
||||
- **"Demodaten zurücksetzen"-Button** → reines Prototypen-Feature; im MVP gegen echte Daten irrelevant
|
||||
- **TODAY hartcodiert** → trivial weggelassen, MVP nutzt echtes Datum
|
||||
- **Hardcoded Produkte (HiveGrid/Asset Control/etc.)** → werden zu echten DB-Datensätzen (Datenmodell Project/GtmStep)
|
||||
- **Off-Brand Visuals (Indigo, Navy, Hellgrau)** → Addendum §5 explizit als Off-Brand-Risiko markiert; NFR-8 verlangt Brand-Konformität
|
||||
- **Detail-Views für Design/Buchhaltung** → Addendum §2.6 (verworfen, weil keine KPIs definiert); PRD §8 Phase 2
|
||||
- **Volltextsuche** → PRD §8, FR-33 als Phase 2
|
||||
- **Mobile/Responsive** → PRD §8, NFR-10 (desktop-first)
|
||||
- **Reaktionen/Kommentare auf Updates** → PRD §8
|
||||
- **Plane-API-Sync / Outline-Embed** → PRD §8, Addendum §1.4 als Phase 2
|
||||
- **Reminders per Mail/Element** → PRD §8
|
||||
- **BI/Reporting-Dashboards** → PRD Anti-Ziel A6, §8
|
||||
- **Zeiterfassung** → PRD Anti-Ziel A4
|
||||
|
||||
---
|
||||
|
||||
## Gaps (potenziell vergessen — Empfehlung: aufnehmen oder bewusst rejizieren)
|
||||
|
||||
### Mittlere Priorität
|
||||
|
||||
1. **Won-Deals als KPI im Vertriebs-Header**
|
||||
- Prototyp: KPI-Tile "Gewonnen (# + EUR)" in Vertriebs-Bereichsstats.
|
||||
- PRD: nicht erwähnt. FR-13 zeigt nur Deal-Karten, kein Won-Aggregat.
|
||||
- Empfehlung: als KPI aufnehmen oder explizit rejizieren (z. B. "Won-Deals werden nicht separat geführt; abgeschlossene Deals verschwinden aus Kanban").
|
||||
|
||||
2. **Gewichteter Pipeline-Wert (Σ value × Stage-Wahrscheinlichkeit)**
|
||||
- Prototyp: explizite KPI mit Wahrscheinlichkeiten pro Stage (0.10/0.25/0.50/0.75/0.90).
|
||||
- PRD: nicht erwähnt; Stage hat keine Wahrscheinlichkeit im Datenmodell.
|
||||
- Empfehlung: in FR-12/13 ergänzen oder als Phase 2 vermerken. Konsequenz für Datenmodell: Stage braucht `probability`-Feld.
|
||||
|
||||
3. **Crosslink-Banner Marketing↔Vertrieb (Lead-Übergabe)**
|
||||
- Prototyp: prominenter Crosslink "X Leads hat Marketing diesen Monat in die Pipeline übergeben" auf Vertriebs- und Marketings-Detail-View.
|
||||
- PRD: implizit via Funnel-letzte-Stufe (FR-16), aber kein expliziter Cross-Area-Link als FR.
|
||||
- Empfehlung: als FR aufnehmen — der Crosslink ist eine sichtbare Verbindung, die "Areas reden miteinander" konkret macht. Vibe-relevant.
|
||||
|
||||
4. **Streak nicht nur pro Person, sondern pro Projekt ("Update-Serie")**
|
||||
- Prototyp: Projekt-Detail zeigt eigenen Streak-Counter "Tage am Stück mit Update".
|
||||
- PRD: FR-24 nennt Streak nur pro Person.
|
||||
- Empfehlung: in FR-24 erweitern oder als separates FR ergänzen — Projekt-Streak ist ein gutes Versandungs-Frühwarnsystem (deckt sich mit Addendum §4 Indikator 2: "Median-Update-Alter pro Projekt").
|
||||
|
||||
5. **Bereichs-Stats-Kacheln in Detail-Views (4er-Grid mit "Projekte/Im Plan/Meilensteine/Updates heute")**
|
||||
- Prototyp: jede Area-Detail-View hat 4 Stats-Tiles als sekundärer Übersichtskopf.
|
||||
- PRD: nicht spezifiziert, nur einzeln in FR-5 (Puls). Detail-Views haben keine Stat-Tile-Anforderung.
|
||||
- Empfehlung: in FR-7/12/16 als "Bereichskopf mit 4 Kennzahlen" ergänzen — sonst gehen Detail-Views in Wireframes verloren.
|
||||
|
||||
### Niedrigere Priorität (qualitativ / UX-Feinheiten)
|
||||
|
||||
6. **Due-Date-Färbung (warn = amber ≤4 Tage, over = rot überfällig)**
|
||||
- Prototyp: konkretes UI-Pattern in Projekt-Rail und Meilensteinen.
|
||||
- PRD: NFR-/FR-Ebene zu generisch — keine explizite Färblogik.
|
||||
- Empfehlung: in UX-Phase festhalten; nicht zwingend im PRD, aber sollte als "Pattern" im Addendum §6 oder UX-Brief landen.
|
||||
|
||||
7. **Avatar mit Initialen + Personen-Farbe als wiederkehrendes UI-Atom**
|
||||
- Prototyp: starkes Identitäts-Element, durchgängig in Feed-Items.
|
||||
- PRD: nicht erwähnt — nur "Person" als Feld.
|
||||
- Empfehlung: UX-Phase-Detail; jeder User braucht `color` im Datenmodell.
|
||||
|
||||
8. **"Bereichsübergreifend offen"-Chip-Liste als Puls-Element**
|
||||
- Prototyp: explizit rechte Foot-Sektion des Puls.
|
||||
- PRD: FR-2 nennt nur "Liste"; das Pattern als rote Chips mit Klick→Area ist nicht spezifiziert.
|
||||
- Empfehlung: in FR-2 spezifizieren oder UX-Phase.
|
||||
|
||||
9. **Kampagnen-Status "pausiert"**
|
||||
- Prototyp: 5 Status (läuft / geplant / ausgewertet / blockiert / pausiert).
|
||||
- PRD/Addendum: FR-17 nennt nur 3 (Aktiv / Pausiert / Beendet); Addendum Datenmodell hat (active|paused|done).
|
||||
- Empfehlung: Status-Set vereinheitlichen. Entweder Prototyp-Set übernehmen (granularer) oder explizit auf 3 Status reduzieren.
|
||||
|
||||
10. **Toast-Komponente für Bestätigungs-Feedback**
|
||||
- Prototyp: explizit nach Update-Submit.
|
||||
- PRD: nicht erwähnt.
|
||||
- Empfehlung: UX-Phase; trivial, sollte aber bewusst entschieden sein.
|
||||
|
||||
11. **Side-Nudge "Der Rhythmus"-Erklärtext im Tagesupdate-Formular**
|
||||
- Prototyp: rechte Spalte im Formular mit Erklärtext zum Rhythmus + Jourfixe.
|
||||
- PRD: nicht spezifiziert; PRD-Tonalität ist eher knapp.
|
||||
- Empfehlung: bewusst entscheiden — passt zur "Disziplin"-Vision und macht den Sinn des Updates explizit (gut für Onboarding, UJ-4).
|
||||
|
||||
### Konzeptionelle Lücken
|
||||
|
||||
12. **Vibes/Onboarding-Splash "Welt der Übersicht"**
|
||||
- Prototyp: Splash-Screen mit personalisierter Begrüßung ("Willkommen, Haris").
|
||||
- PRD: UJ-4 nennt nur "leerer Mein-Status-Bereich + Hinweis".
|
||||
- Empfehlung: Tonalität (locker, persönlich, motivierend) bewusst entscheiden — wenn das Cockpit "Disziplin ohne Druck" sein soll, könnte ein personalisierter Empfang Wirkung haben. Im PRD nicht erwähnt.
|
||||
|
||||
13. **Kein expliziter "Tonalität / Voice"-Abschnitt im PRD**
|
||||
- Prototyp-Texte sind eher locker-geschäftlich ("Alle Tagesupdates sind da. Stark.", "läuft nach Plan", "brauche Hilfe").
|
||||
- PRD spricht nüchtern in Capability-Sprache; keine Voice-/Tonalitäts-Vorgabe.
|
||||
- Empfehlung: kurzen "Voice & Tone"-Abschnitt im UX-Brief — siehe eigener Abschnitt unten.
|
||||
|
||||
---
|
||||
|
||||
## Vokabular-Konsistenz
|
||||
|
||||
| Begriff | Prototyp | PRD | Addendum | Status |
|
||||
|---|---|---|---|---|
|
||||
| **Puls / Unternehmens-Puls** | ✓ Hauptbegriff | ✓ FR-1..5 | ✓ §6 | konsistent |
|
||||
| **Areas / Bereich** | ✓ beide | ✓ "Area" und "Bereich" gemischt | ✓ "Area / Bereich" | konsistent (Doppelbegriff) |
|
||||
| **Ampel** | ✓ | ✓ ("Ampelstatus") | ✓ | konsistent |
|
||||
| **Im Plan / Gefährdet / Blockiert** | ✓ | ✓ FR-19 | ✓ §6 | konsistent |
|
||||
| **Bezug** | ✓ | ✓ FR-19 | ✓ §6 | konsistent |
|
||||
| **Streak / Update-Serie** | ✓ beide nebeneinander | ✓ "Streak" (FR-24) | ✓ "Streak" | **Inkonsistenz:** "Update-Serie" als Synonym fehlt im PRD/Addendum |
|
||||
| **Jourfixe** | ✓ | ✓ FR-4, G4 | ✓ §6 | konsistent |
|
||||
| **Meilenstein** | ✓ | ✓ FR-8, FR-10 | ✓ §6 | konsistent |
|
||||
| **Deal-Stage / Stages** | ✓ | ✓ FR-12 | ✓ "Deal-Stage" §6 | konsistent |
|
||||
| **GTM / GTM-Schritt** | ✓ "GTM" und "Vertriebsfortschritt je Produkt" | ✓ FR-14 ("GTM-Schritt-Liste") | ✓ §6 | konsistent (PRD nutzt "GTM-Schritt" sauber) |
|
||||
| **Lead-Funnel** | ✓ | ✓ FR-16 | ✓ §6 | konsistent |
|
||||
| **MQL** | ✓ | ✓ FR-16 | ✓ §6 | konsistent |
|
||||
| **Blocker** | ✓ | ✓ Feature-Gruppe G | ✓ §6 | konsistent |
|
||||
| **Bereichsübergreifend offen** | ✓ | (sinngemäß FR-2 "bereichsübergreifende Liste") | – | **Begriff fehlt wörtlich** — sollte im PRD/Addendum als Label übernommen werden |
|
||||
| **Crosslink** | ✓ | – | – | **Begriff fehlt** (Konzept ist nur implizit) |
|
||||
| **Umsatz / Intern (Projekt-Gruppen)** | ✓ | ✓ FR-7 | ✓ §1.2 (`group umsatz|intern`) | konsistent |
|
||||
| **Pipeline-Wert / Gewichteter Wert** | ✓ beide | – | – | **fehlt** (siehe Gap #2) |
|
||||
| **Reichweite** | ✓ | ✓ FR-16 | – | konsistent |
|
||||
| **Übergabe / An Vertrieb übergeben** | ✓ | ✓ FR-16 ("Übergabe") | ✓ "Übergabe" §6 | konsistent |
|
||||
| **Kampagne / Kampagnen-Status** | ✓ 5 Status | ✓ 3 Status (FR-17) | ✓ 3 Status | **Inkonsistenz im Status-Set** (siehe Gap #9) |
|
||||
| **Pflichtenheft** | ✓ Prototyp-Kontext | – | – | nicht übernommen; vermutlich projektspezifisch, kein Produkt-Vokabular |
|
||||
| **Demodaten** | ✓ | – | – | bewusst weggelassen (Prototyp-Spezifikum) |
|
||||
|
||||
### Empfehlungen Vokabular
|
||||
- "Bereichsübergreifend offen" als Label in FR-2 verankern.
|
||||
- "Update-Serie" als deutscher Synonym-Begriff zu "Streak" im Addendum §6 ergänzen — der Prototyp nutzt beide nebeneinander.
|
||||
- "Crosslink" als Konzept im Addendum §6 aufnehmen, falls Gap #3 angenommen wird.
|
||||
- Kampagnen-Status-Set zwischen Prototyp (5) und Addendum (3) auflösen.
|
||||
|
||||
---
|
||||
|
||||
## Tonalität / Vibe
|
||||
|
||||
### Beobachtungen Prototyp
|
||||
Der Prototyp hat einen sehr spezifischen Voice-Charakter:
|
||||
- **Locker-geschäftlich:** "Alle Tagesupdates sind da. Stark." | "Aktuell keine offenen Blocker." | "Stark." als Erfolgs-Quittung.
|
||||
- **Persönlich-direkt:** "Willkommen, Haris" | "Was hast du heute geschafft?" | "Wie läuft es?" | "Etwas, das dich blockiert oder wobei du Hilfe brauchst?"
|
||||
- **Konkret statt abstrakt:** "in 3 Tagen", "heute fällig", "2 Tage überfällig" — Mensch-lesbare Datumsangaben statt ISO-Strings.
|
||||
- **Bilder/Metaphern:** "Puls", "Welt der Übersicht" (im Header-Konzept), "Rhythmus" (Side-Nudge im Formular), "Areas" (englisch eingedeutscht).
|
||||
- **Kein Corporate-Sprech:** keine "KPIs", keine "Stakeholder", keine "Initiativen" im UI-Copy.
|
||||
|
||||
### Was PRD/Addendum vom Vibe einfangen
|
||||
- PRD hat in §1 selbst einen klaren "Voice": *"kein Tool-Problem, sondern ein Durchziehen-Problem"* | *"niemand sich tagelang wegducken kann"* | Vision §1.2 ist sehr direkt. **Das ist gut.**
|
||||
- Anti-Ziele §2.2 sind in derselben direkten Tonalität gehalten.
|
||||
- Persona-Beschreibungen in Addendum §3 fangen Tonalität ("Lästert intern", "will stolz zeigen") und Persönlichkeit gut ein.
|
||||
|
||||
### Was verloren geht
|
||||
- **"Welt der Übersicht"** als emotionaler Header-Claim ist nicht im PRD prominent. Wenn dieser Anspruch ("Übersicht als Welt, in der man sich aufhält") tragend sein soll, gehört er in §1.2 (Vision) oder als eigener Abschnitt "Voice & Tone".
|
||||
- **Personalisierter Empfang ("Willkommen, [Name]")** ist im Onboarding (UJ-4) zu nüchtern beschrieben — verliert den "Mensch wird begrüßt"-Vibe.
|
||||
- **Konkrete Mensch-Sprache in UI-Copy** (z. B. "Wie läuft es?", "brauche Hilfe", "Stark.") ist nirgends als Voice-Anweisung dokumentiert. Risiko: ein Implementations-Team baut "Status auswählen" statt "Wie läuft es?" — funktional richtig, vibe-mäßig tot.
|
||||
- **Rhythmus-Erklärtext** (Side-Nudge "Der Rhythmus") als pädagogisches Element ist nicht spezifiziert — das erzählt User aber, warum tägliches Update überhaupt Sinn macht.
|
||||
- **Lockere Quittungen** ("Stark.", "Alle Tagesupdates sind da.") sind FR-frei und drohen, im Bau durch "Update erfolgreich gespeichert." ersetzt zu werden.
|
||||
|
||||
### Empfehlung
|
||||
Ein expliziter Abschnitt **"Voice & Tone"** in PRD oder Addendum mit:
|
||||
- Beispiel-Copy aus Prototyp als Referenz ("Stark.", "Wie läuft es?", "Willkommen, [Name]").
|
||||
- Verbot von Corporate-Sprech in UI (keine "KPIs", keine "Submit"-Buttons — stattdessen "Tagesupdate absenden").
|
||||
- Mensch-lesbare Datumsangaben Pflicht ("in 3 Tagen", nicht "2026-05-28").
|
||||
- Personalisierung mit Vornamen wo sinnvoll.
|
||||
- Header-Claim "Welt der Übersicht" oder vergleichbar als emotionale Klammer entscheiden (übernehmen oder bewusst rejizieren).
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
- **Vollständig abgedeckt:** ~50 Items (alle Haupt-Features, Datenmodell-Entitäten, Hauptbegriffe).
|
||||
- **Bewusst weggelassen mit Begründung:** ~13 Items (Prototyp-Spezifika, Phase-2-Kandidaten, Off-Brand-Visuals, Anti-Ziele).
|
||||
- **Gaps zur Diskussion:** 13 Items — davon 5 mittlere Priorität (Won-Deals, gewichteter Pipeline-Wert, Crosslinks, Projekt-Streak, Detail-View-Stats) und 8 UX-/Vibe-Lücken.
|
||||
- **Vokabular:** überwiegend konsistent; vier kleinere Lücken (Update-Serie, Bereichsübergreifend offen, Crosslink, Kampagnen-Status-Set).
|
||||
- **Tonalität:** PRD hat eigenen guten Voice für Strategie/Anti-Ziele, aber UI-Copy-Tonalität des Prototyps ("Stark.", "Wie läuft es?", personalisierter Empfang) ist nirgends als Voice-Anweisung dokumentiert. Empfehlung: "Voice & Tone"-Abschnitt nachziehen.
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# PRD Quality Review — ToolCTRL Unternehmens-Cockpit
|
||||
|
||||
## Overall verdict
|
||||
|
||||
**Good.** Das PRD ist ein angemessen kalibriertes Internal-Tool-Dokument mit klarer These ("Durchziehen-Problem, nicht Tool-Problem"), ehrlichem Scope (3 Areas statt 5, harte Anti-Ziele, sauber gepflegte Out-of-Scope-Tabelle) und einem zur Stake-Höhe passenden Detaillierungsgrad. Es trägt die Entscheidungen sichtbar (Daily-Update-Pflicht, flaches Vertrauensmodell, Authentik-OIDC) und benennt die wirklichen Risiken — insbesondere das Meta-Risiko, dass das Cockpit selbst versanden könnte. Was am ehesten wackelt, ist die Done-ness-Schärfe einzelner FRs (mehrere Adjektive ohne Bound) und ein paar leise Vagheiten in den NFRs; downstream lassen sich die Stories dennoch sauber ableiten.
|
||||
|
||||
## Decision-readiness — strong
|
||||
|
||||
Das PRD legt seine Entscheidungen offen statt sie zu verstecken. § 1.1 nennt das Kernproblem in einem Satz ohne Hedge ("kein Tool-Problem, sondern ein Durchziehen-Problem"). § 4.1 deklariert das flache Vertrauensmodell ("Alle Rollen sehen alles") und § 10 bestätigt, dass diese Entscheidung bewusst gegen Sales-Sensibilität gewählt wurde — das ist gelebter Trade-off, nicht Smoothing. § 2.2 listet sechs Anti-Ziele, jedes mit Reibung zu einer plausiblen Alternative (Ticketsystem, Zeiterfassung, BI). § 3.2 führt drei Gegenmetriken, davon zwei (CM2/CM3) explizit qualitativ — mit `[NOTE FOR PM]`-Verankerung der 4-Wochen-Retro. § 9 enthält fünf echte Open Questions mit benannten Entscheidern und Fristen ("Vor MVP-Bau"), nicht rhetorisch.
|
||||
|
||||
Was die Decision-readiness vom Status `strong` hält statt sie zu trüben: Die Top-Entscheidung "Daily-Update-Pflicht mit öffentlicher fehlt-heute-Liste" wird zwar in § 1.1 und FR-3 begründet, aber der explizite Trade-off — Disziplin-Mechanik gegen das Risiko Lästergefühl — taucht erst verteilt in CM3 und § 10 Risiko 2 auf. Ein einziger Decision-Block "Wir haben uns für Pflicht-mit-Öffentlichkeitsdruck entschieden, obwohl …" würde das Bild schärfer machen, ist aber für ein Internal-Tool dieses Stake-Levels Kosmetik.
|
||||
|
||||
### Findings
|
||||
- **low** Trade-off zur Pflicht-Mechanik liegt verstreut (§ 1.1, § 3.2 CM3, § 10) — *Fix:* Optional einen 3-Zeilen-Decision-Block "Pflicht vs. freiwillig" in § 1.1 oder § 2 mit explizitem Was-wir-aufgeben.
|
||||
|
||||
## Substance over theater — strong
|
||||
|
||||
Das PRD verzichtet konsequent auf Theater-Bausteine. Es gibt **keine** Differenzierungs-/Innovations-Sektion (richtig, weil intern). Die Personas in § 4.1 sind funktional (eine Tabelle, eine Spalte "Hauptnutzen") und werden in FRs und UJs auch tatsächlich gebraucht — UJ-1 lebt vom Entwickler, UJ-2 vom PM, UJ-4 vom Onboarding. Die In-Depth-Personas (Addendum § 3) sind in den Addendum verschoben, wo sie für die UX-Phase Sinn ergeben, ohne das Hauptdokument zu verstopfen.
|
||||
|
||||
Die Vision in § 1.2 ist produktspezifisch ("in unter 60 Sekunden", "niemand sich tagelang wegducken kann") — sie ließe sich nicht ohne Edit in ein anderes Cockpit-PRD übertragen. § 1.3 "Warum jetzt" liefert konkrete Vergangenheit (Jira/Confluence aufgegeben, "schneller ohne PM" gescheitert), keine generischen Plattitüden.
|
||||
|
||||
Die NFRs in § 7 sind teilweise hart (NFR-1 ≤ 1,5 s bei 6-15 Nutzern und 90 Tagen Historie; NFR-2 ≤ 500 ms; NFR-9 ≤ 3 Klicks; CM1 ≤ 60 s Median), teilweise weicher (NFR-3 99% in Mo-Fr 8-19 — passend für Internal; NFR-13 "tägliches Backup, Mechanik in Architektur-Phase"). NFR-8 "Brand-konform … konkrete Tokens in UX-Phase" ist ehrlich vertagt und nicht Theater.
|
||||
|
||||
### Findings
|
||||
*(Keine. Substance-Bar wird für Internal-Stake-Level vollständig getragen.)*
|
||||
|
||||
## Strategic coherence — strong
|
||||
|
||||
Das PRD hat eine sichtbare These: **Versandung ist ein Sichtbarkeits-/Disziplin-Problem, kein Werkzeug-Problem.** Diese These zieht sich durch:
|
||||
|
||||
- § 1.1 framt das Problem
|
||||
- § 2 G1 ("≥1 Tagesupdate pro Werktag") und G2 ("Ampelstatus jederzeit erkennbar") operationalisieren die These
|
||||
- FR-1/FR-3 (Puls + Heute-Quote), FR-19 (Update-Formular), FR-24/FR-25 (Streak + Fehlt-Liste), FR-2 (Blocker-Liste) sind allesamt direkte Hebel auf die These
|
||||
- M1-M3 messen genau die These, nicht Vanity (M1 Update-Quote, M2 Blocker-Time-to-Visible, M3 Ampel-Frische); M4-M5 messen Verankerung im Arbeitsalltag
|
||||
- Die drei Mechanik-Indikatoren im Addendum § 4 (Update-Quote, Median-Update-Alter, Overdue-Meilensteine) sind die operative Übersetzung der These — selten so sauber.
|
||||
|
||||
Die Feature-Priorisierung folgt der These: 3 Detail-Areas statt 5, weil Buchhaltung/Design keine etablierten KPIs haben (Addendum § 2.6); Plane/Outline-API-Sync bewusst Phase 2 (§ 8) — weil API-Komfort die These nicht stützt, Sichtbarkeit aber sofort braucht. Gegenmetriken (§ 3.2) existieren und benennen explizit, dass die Pflicht-Mechanik kippen kann.
|
||||
|
||||
MVP-Scope-Kind ist "problem-solving" (nicht Plattform, nicht Revenue), und die Scope-Logik passt: Drei Bereiche, drei Mechaniken, eine Startseite. Kein Backlog mit Section-Headern.
|
||||
|
||||
### Findings
|
||||
*(Keine substanziellen.)*
|
||||
|
||||
## Done-ness clarity — adequate
|
||||
|
||||
Mehrheit der FRs trägt mindestens eine testbare Konsequenz. Gute Beispiele:
|
||||
|
||||
- FR-3: "X von Y Mitarbeitern haben heute gepingt" mit klickbarer Fehlt-Liste — eindeutig testbar
|
||||
- FR-12: "5-Spalten Kanban: Lead → Erstgespräch → Demo → Angebot → Abschluss. Drag-and-Drop" — Stages explizit
|
||||
- FR-16: 5 Funnel-Stufen explizit benannt
|
||||
- FR-20: "unveränderbar nach 30 Minuten" — präzise Schwelle
|
||||
- FR-25: "Werktag, nach 11:00" — Bedingung scharf
|
||||
|
||||
Wo die Schärfe wackelt:
|
||||
|
||||
- **FR-1** "aggregiertem Ampelstatus" — die Aggregations-Regel ist undefiniert. Wird die Area-Ampel aus dem schlechtesten Projekt abgeleitet, aus der Mehrheit, gewichtet? Engineering muss raten oder rückfragen.
|
||||
- **FR-8** "Ampelstatus (abgeleitet aus letztem Update)" + **FR-23** "zuletzt gewinnt" — die Regel ist hier definiert (zuletzt gewinnt), aber FR-1's Area-Aggregation bleibt offen.
|
||||
- **FR-5** "3-5 Key-Metrics pro Area (z. B. offene Deals, aktive Kampagnen, Projekte im Plan vs. gefährdet)" — die "z. B."-Beispiele sind keine Spezifikation. Welche Metriken konkret pro Area? Das verlässt das PRD ohne Festlegung.
|
||||
- **FR-11** "schreibbar durch jede Person, Audit via `created_by` / `updated_by`" — keine Acceptance dafür, was bei Lösch-/Archiv-Operationen passiert (OQ-5 deckt nur den Archiv-Sub-Fall ab).
|
||||
- **FR-21** "chronologisch angezeigt" — neueste oben oder älteste oben? Pagination? Limit?
|
||||
- **NFR-1** Bound präzise; **NFR-3** "99% in Mo-Fr 8-19" angemessen für Internal; **NFR-9** "≤ 3 Klicks" scharf — aber **NFR-8** "Brand-konform" ohne Tokens und **NFR-14** "ohne Migrations-Drama" ist Adjektiv, kein Bound. Für NFR-14 wäre eine Schema-Migration-Strategie-Erwartung ("additive Migrationen, keine Daten-Drops in Phase 2") konkreter.
|
||||
- **OQ-4** (Wer darf Blocker als gelöst markieren?) sollte vor MVP-Bau beantwortet sein — sonst fehlt FR-31 die Berechtigungsregel.
|
||||
|
||||
Insgesamt ist Done-ness ausreichend für ein Internal-Tool dieser Größe, aber die Story-Phase wird an FR-1 (Area-Aggregation), FR-5 (Key-Metrics-Katalog) und FR-21 (Sort-/Pagination) rückfragen.
|
||||
|
||||
### Findings
|
||||
- **medium** FR-1 Area-Ampel-Aggregationsregel fehlt (§ 6.1) — *Fix:* "Worst-of aller Projekt-Ampeln in der Area" oder Alternative festlegen; einen Satz hinzufügen.
|
||||
- **medium** FR-5 Key-Metrics nur als "z. B."-Beispiele (§ 6.1) — *Fix:* Pro Area die 3-5 Metriken namentlich aufzählen, oder explizit als `[ASSUMPTION]` markieren und in OQ aufnehmen.
|
||||
- **low** FR-21 Sortierung/Pagination der Feeds undefiniert (§ 6.5) — *Fix:* "Neueste zuerst, Pagination ≥ N Einträge" oder Äquivalent.
|
||||
- **low** NFR-14 "ohne Migrations-Drama" ist Adjektiv (§ 7.6) — *Fix:* Konkretisieren als "Phase-2-Felder additiv migrierbar; kein Daten-Drop nötig".
|
||||
- **low** OQ-4 (Blocker-Auflöser-Berechtigung) muss vor Story-Bau zu FR-31 gespiegelt werden (§ 9, § 6.7) — *Fix:* Antwort früh holen; danach FR-31 ergänzen.
|
||||
|
||||
## Scope honesty — strong
|
||||
|
||||
Sehr saubere Scope-Disziplin. § 2.2 nennt sechs Anti-Ziele jeweils mit der Alternative, gegen die abgegrenzt wird. § 8 ist eine zehnzeilige Out-of-Scope-Tabelle mit Begründung pro Item — kein "TBD", jeder Eintrag trägt eine Erklärung ("URL-Link reicht zunächst", "Visualisierung zuerst beweisen, dann pushen"). FR-26 (Feiertage), FR-33 (Volltextsuche) und NFR-10 (Mobile) sind explizit als Phase 2 markiert, nicht stillschweigend ausgelassen.
|
||||
|
||||
`[ASSUMPTION]`-Tags sind sparsam, aber an den richtigen Stellen: UJ-4 (Authentik-Group-Mapping), FR-33 (Phase 2-Charakter), § 10 (drei Top-Annahmen zu OIDC-Aufwand, Team-Größe, Backend-Stack). `[NOTE FOR PM]` in § 3.2 verankert die 4-Wochen-Retro für CM2/CM3.
|
||||
|
||||
§ 9 hat fünf Open Questions mit Entscheider und Frist. Open-Items-Density: ca. 5 OQs + 4 `[ASSUMPTION]` + 1 `[NOTE FOR PM]` = 10 Open-Items bei einem MVP-PRD mit 33 FRs und 14 NFRs — angemessen für ein 4-Wochen-MVP, das in die Architektur-Phase übergeht. Keines der Items blockiert den Green-Light-Beschluss zum Bau; OQ-3 und OQ-4 sollten allerdings vor Story-Erstellung beantwortet sein.
|
||||
|
||||
### Findings
|
||||
- **low** Assumptions-Index fehlt am Ende des PRD (§ 11 endet ohne Annex) — *Fix:* Optional einen "Annahmen-Index"-Block am Ende, der die vier `[ASSUMPTION]`-Inlines auflistet. Für Internal-Stake nicht zwingend.
|
||||
|
||||
## Downstream usability — adequate
|
||||
|
||||
Glossar ist im Addendum § 6 vorhanden und sauber gepflegt (12 Begriffe: Puls, Area, Tagesupdate, Bezug, Ampel, Blocker, Streak, Jourfixe, Meilenstein, Deal-Stage, GTM-Schritt, Lead-Funnel). Die Begriffe werden im PRD konsistent verwendet — "Puls" durchgängig in § 6.1, FR-2, FR-25; "Area" durchgängig statt Mischung mit "Bereich" (Achtung: "Bereich" wird parallel benutzt, siehe Mechanical Notes).
|
||||
|
||||
FR-IDs sind kontinuierlich (FR-1 bis FR-33), keine Lücken, keine Duplikate. NFRs analog (NFR-1 bis NFR-14). UJs (UJ-1 bis UJ-4), Metriken (M1-M5), Gegenmetriken (CM1-CM3), Anti-Ziele (A1-A6), Open Questions (OQ-1 bis OQ-5) — alle ID-Räume sauber, lokal benannt, eindeutig.
|
||||
|
||||
UJs nennen Personas implizit ("PM", "Owner", "Mitarbeiter", "Person") — nicht via exakter Label aus § 4.1. UJ-2 sagt "PM" (§ 4.1 hat "Product / PM"), UJ-3 sagt "PM oder Owner" (§ 4.1 hat "Geschäftsführung / Owner"). Das ist keine Katastrophe, aber Downstream-Story-Erstellung wird einmal nachschauen müssen. Für ein Internal-Tool akzeptabel; bei chain-top-Status mit UX-Phase davor wäre eine Persona-Label-Säuberung nützlich.
|
||||
|
||||
Cross-References funktionieren: FR-3 referenziert klar FR-25, FR-2 spiegelt zu FR-30. Sektionen sind extrahierbar — z. B. § 6.5 (Tagesupdates) kann allein gelesen werden, ohne "siehe oben"-Verweise.
|
||||
|
||||
Das PRD ist explizit Chain-Top — § 0 sagt "Tech-Entscheidungen leben im begleitenden `addendum.md` und in der späteren Architektur-Phase", § 11 plant Phase-2-Review. Downstream-Usability ist damit eine relevante Dimension.
|
||||
|
||||
### Findings
|
||||
- **medium** UJ-Persona-Linkage nicht via exakter § 4.1-Label (§ 5) — *Fix:* "PM" → "Product / PM"; "PM oder Owner" → "Product / PM oder Geschäftsführung / Owner". Klein, aber spart Reibung.
|
||||
- **low** "Bereich" und "Area" werden parallel genutzt (z. B. FR-19 "Bereich", Addendum § 6 nennt beide als Synonym) — *Fix:* Glossar-Eintrag explizit als gleichwertig erklären (ist faktisch schon dort), oder konsequent eine Form wählen. Aktuell tolerierbar.
|
||||
|
||||
## Shape fit — strong
|
||||
|
||||
Das PRD ist als **Internal-Tool / Multi-Operator-Kapazitäts-Spec** geformt, was zum Produkt passt (6-15 Nutzer, kein Verkauf, mehrere Rollen mit unterschiedlichen Workflows). Personas und UJs sind vorhanden und tragen — sie sind nicht overhead, denn das Cockpit hat echte Rollendifferenz (Sales braucht Kanban, Dev braucht Meilensteine, GF liest). SMs sind operational/Quoten-basiert (M1 Update-Quote, M3 Ampel-Frische), nicht User-Acquisition — passt zum Internal-Stake.
|
||||
|
||||
Was richtig dosiert ist:
|
||||
|
||||
- Keine Differentiator-Sektion (brauchts nicht — kein Verkauf)
|
||||
- Keine Investor-Pitch-Schicht
|
||||
- Keine GTM-Strategie
|
||||
- Personas knapp tabellarisch + In-Depth ins Addendum verschoben
|
||||
- NFR-Tiefe auf Internal-Verfügbarkeit (Mo-Fr 8-19, 99%) statt 24/7-SLA
|
||||
- Brand/UX explizit auf UX-Phase verschoben
|
||||
|
||||
Was nicht over-formalized ist: Keine UJ-Inflation (vier UJs decken die Hauptpfade — Update schreiben, Jourfixe, Verfall-Detect, Onboarding — und das genügt für ein Cockpit). Keine 10-Persona-Parade.
|
||||
|
||||
Was nicht under-formalized ist: Das PRD hat eine Vision, eine These, Erfolgsmetriken, Gegenmetriken, Open Questions, Risiken — alles vorhanden, ohne aufgebläht zu wirken.
|
||||
|
||||
### Findings
|
||||
*(Keine.)*
|
||||
|
||||
## Mechanical notes
|
||||
|
||||
- **Glossar-Drift:** "Bereich" und "Area" werden synonym verwendet; das Glossar (Addendum § 6) führt sie als gleichwertig ("Area / Bereich"). Konsistent, aber nicht ganz sauber — z. B. FR-19 nutzt "Bereich", FR-1 nutzt "Areas" im Plural. Tolerierbar.
|
||||
- **ID-Kontinuität:** FR-1 bis FR-33 lückenlos. NFR-1 bis NFR-14 lückenlos. UJ-1 bis UJ-4, M1-M5, CM1-CM3, A1-A6, G1-G5, OQ-1 bis OQ-5 — alle sauber. Keine Duplikate.
|
||||
- **Cross-References:** FR-3 ↔ FR-25 konsistent. FR-2 ↔ FR-30 konsistent. § 9 OQ-3/OQ-4 referenzieren implizit FR-19/FR-31 — sollte vor Story-Bau gespiegelt werden.
|
||||
- **Assumptions-Index-Roundtrip:** Vier inline `[ASSUMPTION]`-Tags (UJ-4, FR-33, § 10 dreifach). Kein konsolidierter Index am Ende. Für Internal-Stake nicht zwingend, aber bei Chain-Top-Lesung praktisch.
|
||||
- **UJ-Persona-Linkage:** UJs nennen Rollen nicht via exakter § 4.1-Label — siehe Downstream-Findings.
|
||||
- **Erforderliche Sektionen:** Vision, Ziele, Anti-Ziele, SMs, CMs, Personas, UJs, FRs, NFRs, Out-of-Scope, Open Questions, Risks/Assumptions, Review-Punkte — alle vorhanden. Glossar ins Addendum verlagert (akzeptabel; man könnte einen einzeiligen Verweis aus dem PRD ins Addendum-Glossar setzen).
|
||||
- **Addendum-Aufgabentrennung:** Tech-Skizze, verworfene Alternativen, In-Depth-Personas, Mechanik-Detail, Off-Brand-Risiken, Glossar — sauber im Addendum, hält das PRD lesbar. Das ist eine starke Trennung.
|
||||
@@ -0,0 +1,789 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
status: 'complete'
|
||||
completedAt: '2026-05-25'
|
||||
inputDocuments:
|
||||
- prds/prd-projectmanagement-2026-05-25/prd.md
|
||||
- prds/prd-projectmanagement-2026-05-25/addendum.md
|
||||
- prds/prd-projectmanagement-2026-05-25/.decision-log.md
|
||||
- prds/prd-projectmanagement-2026-05-25/inputs/unternehmens-cockpit.html
|
||||
- prds/prd-projectmanagement-2026-05-25/inputs/ToolCTRL_Projektmanagement-Leitfaden_v3.0.pdf
|
||||
- prds/prd-projectmanagement-2026-05-25/extract-prototype-html.md
|
||||
- architecture.md
|
||||
workflowType: 'ux-design'
|
||||
project_name: 'ToolCTRL Unternehmens-Cockpit'
|
||||
user_name: 'Dejancosic'
|
||||
date: '2026-05-25'
|
||||
binding_frame: 'ToolCTRL Projektmanagement-Leitfaden v3.0 (Mai 2026)'
|
||||
prd_status: 'final'
|
||||
architecture_status: 'complete'
|
||||
---
|
||||
|
||||
# UX Design Specification — ToolCTRL Unternehmens-Cockpit
|
||||
|
||||
**Author:** Dejancosic
|
||||
**Date:** 2026-05-25
|
||||
|
||||
> Dieses Dokument legt das Visual Design, die Komponenten-Bibliothek, die Layouts und die Mikrocopy fest. Es operationalisiert die UX-Anforderungen aus PRD (NFR-8, NFR-9, NFR-12/13) und liefert die Brand-Token-Werte (OQ-2), die in `tailwind.config.ts` einfließen.
|
||||
|
||||
---
|
||||
|
||||
<!-- UX-Content wird Schritt für Schritt angehängt -->
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Project Vision
|
||||
|
||||
Das **ToolCTRL Unternehmens-Cockpit** ist ein internes Web-Werkzeug, das jeden Werktag in unter 60 Sekunden sichtbar macht, woran das Team arbeitet, was klemmt und welche Projekte aus dem Plan laufen. Es operationalisiert den Projektmanagement-Leitfaden v3.0 als drittes Werkzeug neben Plane (Arbeit) und Outline (Gedächtnis) und ist die Antwort auf das wiederkehrende „Wir starten viel, wir bekommen nichts fertig"-Muster.
|
||||
|
||||
UX-Aufgabe: Eine Anwendung schaffen, die sich **unaufgeregt, lesbar und schnell** anfühlt — kein „Tool, das einen checkt", sondern ein gemeinsamer Spiegel des Tages. Die Anwendung muss Disziplin erzeugen, ohne Lästergefühl zu produzieren (PRD CM3).
|
||||
|
||||
### Target Users
|
||||
|
||||
Alle ToolCTRL-Mitarbeiter (6-15 Personen), aufgeteilt in zwei Sichtebenen:
|
||||
|
||||
**Leitfaden-Rollen (pro Projekt):**
|
||||
|
||||
- **P1 Projektverantwortliche/r** — pflegt Ampelstatus und Trend, moderiert Jourfixe, achtet auf Pflicht-Minimum.
|
||||
- **P2 Team** — schreibt Tagesupdates, setzt Blocker, pflegt Meilenstein-Status.
|
||||
- **P3 Product Owner** — Lese-Schwerpunkt, Priorisierungs-Diskussionen.
|
||||
- **P4 Auftraggeber** (intern = Geschäftsführung) — Lese-Hauptnutzer für Puls und Pflicht-Minimum.
|
||||
|
||||
**Querschnittsrollen (alle Mitarbeiter):**
|
||||
|
||||
- **Q1 Entwickler** — Updates mit Projektbezug, viele Bezugswechsel pro Tag, möchte unter 30 s pro Touchpoint.
|
||||
- **Q2 Sales** — lebt im Kanban, verschiebt 5–10 Deals pro Woche, schreibt Updates ungern wenn das System träge ist.
|
||||
- **Q3 Marketing** — pflegt Lead-Funnel + Kampagnen, weniger täglicher Fortschritt → das System darf „heute kein Fortschritt, aber dran"-Updates nicht verteufeln.
|
||||
- **Q4 Design / Q5 Buchhaltung** — schreiben „Allgemein"-Updates, brauchen keine Projektpflicht.
|
||||
|
||||
Stand der Nutzer-Reife: hoch tech-affin (alle haben ein Authentik-SSO-Login bereits gewohnheitsmäßig), Desktop-First-Nutzer, Deutsch als Arbeitssprache.
|
||||
|
||||
### Key Design Challenges
|
||||
|
||||
1. **Zwei Touchpoints pro Tag, beide unter 60 Sekunden.** Morgen-Einstempeln + ToDos und Abend-Reflexion müssen *sehr* schnell zu erledigen sein — sonst erodiert die Disziplinmechanik (CM1a/CM1b).
|
||||
2. **Sichtbarkeitsmodell pre/post Publikation.** Innerhalb des Tages: ToDos sind privat, nur „eingestempelt: ja/nein" für Kollegen sichtbar. Ab nächstem Werktagsmorgen: konsolidierter Eintrag wird öffentlich. Die UI muss diese Privatheit ohne Erklärungstext verständlich machen.
|
||||
3. **Pflicht-Minimum als Erstklass-Information.** Die 6 Leitfaden-Pflicht-Punkte pro Projekt müssen prominent und auf einen Blick lesbar sein — sie sind die Brücke zwischen Cockpit und Leitfaden, nicht eine Detailseite.
|
||||
4. **Drei strukturell unterschiedliche Bereichs-Details.** Entwicklung (Projekte+Meilensteine), Vertrieb (Kanban+gewichtete Pipeline), Marketing (Lead-Funnel+Kampagnen) sehen visuell anders aus, gehören aber in dasselbe Layout-Skelett — das verlangt eine konsistente Schale mit feature-spezifischem Inhalt.
|
||||
5. **Drei Status-Ampelfarben in einer monochrom-geprägten Brand.** Brand ist schwarz/weiß; Ampel braucht Grün/Gelb/Rot semantisch. Sie müssen funktional erkennbar bleiben, ohne die Brand-Sprache zu sprengen.
|
||||
6. **Voice & Tone als Disziplin-Anker.** Locker und direkt (`„Stark."`, `„Wie läuft es?"`) — UI-Mikrocopy ist Pflicht-Vehikel, kein Beiwerk.
|
||||
|
||||
### Design Opportunities
|
||||
|
||||
1. **Ein einzelner „Puls"-Bildschirm als tägliche Heimat.** Wenn der Puls in einem Blick die Antwort auf „Was muss ich heute wissen?" liefert, gewinnt das Cockpit täglich. Konsequente Information-Density ohne Overload.
|
||||
2. **„Mein heute" als persönliche Tagesoase.** Eine ruhige, private Sicht, die der einzige Ort ist, wo ToDos heute bearbeitbar sind — visuell von der öffentlichen Puls-Sicht trennen.
|
||||
3. **Reflexion als geführter, kurzer Flow.** Statt eines Formulars: eine schrittweise Reflexion (ToDos abhaken → Freitext → Ampel + Trend → fertig), die sich wie ein Tagesabschluss anfühlt, nicht wie ein Pflicht-Formular.
|
||||
4. **Markenmäßige Reduktion als Feature.** Brand ist monochrom — das passt zu „weniger ablenken, mehr Übersicht". Statt das als Limit zu sehen, ist es ein Wettbewerbsvorteil gegenüber bunten SaaS-Tools.
|
||||
5. **Der `^`-Caret als wiederkehrender Mikro-Anker.** Das macOS-Ctrl-Symbol als kleines Brand-Element (im Logo, in Tab-Indikatoren, in Bestätigungs-Toasts) zieht eine subtile Identitätslinie durch die App.
|
||||
6. **Sichtbar gemachte Streaks als sozialer Motivator.** Wer 14 Werktage in Folge eingestempelt ist, sieht das prominent im Profil und im „Heute fehlt"-Modul. Sozialer Druck ohne Public Shaming.
|
||||
|
||||
---
|
||||
|
||||
## Core User Experience
|
||||
|
||||
### Defining Experience
|
||||
|
||||
Die Kern-Erfahrung des Cockpits ist der **Tagesrhythmus**: Eine Person stempelt morgens ein und definiert ihre ToDos für den Tag; am Abend reflektiert sie, hakt ab, setzt Ampel und Trend; am nächsten Werktagsmorgen sehen Kollegen den konsolidierten Vortags-Eintrag. Diese Schleife wiederholt sich jeden Werktag und ist der zentrale Wertbeitrag des Produkts. Alles andere — Puls, Bereichs-Details, Pflicht-Minimum, Blocker — ist die Auswertungs- und Übersichts-Schicht über dieser täglichen Routine.
|
||||
|
||||
Der zweite zentrale Moment ist der **Jourfixe-Puls am Dienstag 11:00**: Die Übersichtsseite wird vom PM (P1/P4) als Hauptbildschirm aufgerufen, alle drei Bereiche werden in einer scroll-bereiten Sicht durchgegangen, rote/gelbe Projekte und offene Blocker werden direkt angesprochen.
|
||||
|
||||
### Platform Strategy
|
||||
|
||||
- **Plattform:** Web-Anwendung (SPA), self-hosted unter `cockpit.tool-ctrl.de`.
|
||||
- **Primärer Input:** Maus + Tastatur. Touch-Geräte sind nicht primär, müssen aber das Tagesupdate-Schreiben nicht blockieren.
|
||||
- **Bildschirmgröße:** Desktop-first (≥ 1280 px breit). Mobile/Tablet: lesbar (responsive Layout-Grid), nicht primär schreibbar (PRD NFR-10).
|
||||
- **Offline:** keine Offline-Fähigkeit im MVP. Cockpit ist immer online (interne Infrastruktur).
|
||||
- **Geräte-Capabilities:** kein Spezial-Hardware, kein Push, keine Kamera. Der Browser ist die einzige Bühne.
|
||||
- **Browser-Support:** moderne Evergreen-Browser (Chrome, Firefox, Safari der letzten 2 Major-Versionen). Kein IE11.
|
||||
|
||||
### Effortless Interactions
|
||||
|
||||
Diese Dinge müssen sich **mühelos** anfühlen — wenn hier Reibung entsteht, scheitert das ganze Produkt:
|
||||
|
||||
- **Einstempeln** — Person klickt einen großen CTA auf der Puls-Seite, Modal öffnet sich mit Bereichs-Default und leerer ToDo-Liste. Tippen → Enter → nächster ToDo → Enter. Speichern mit Cmd/Ctrl+S oder Enter auf leerer Zeile. Ziel: ≤ 30 Sekunden für 3-5 ToDos.
|
||||
- **ToDo abhaken in der Reflexion** — Single-Click auf die Checkbox neben jedem ToDo. Optionaler Verwerf-Modus (Rechtsklick oder Hover-Button) für „verworfen mit Grund".
|
||||
- **Ampel + Trend setzen** — drei große farbige Buttons (Grün/Gelb/Rot), nicht ein winziges Dropdown. Trend als drei Pfeil-Buttons (↗︎ → ↘︎).
|
||||
- **Blocker erfassen** — entweder inline beim Reflektieren (Toggle „Blocker?") oder über ein „+ Neuer Blocker"-Button im Header, der von jeder Seite aus erreichbar ist.
|
||||
- **Projekt wechseln** — Bereichs-Detail hat eine Projekt-Rail oben mit Klick-zum-Wechsel; Projekt-Detail hat „← Bereich" als Breadcrumb.
|
||||
- **Login** — automatischer Authentik-Redirect; nach erfolgreichem Login landet die Person auf der Puls-Seite. Kein Login-Formular im Cockpit selbst.
|
||||
|
||||
### Critical Success Moments
|
||||
|
||||
1. **Morgen-Eintritt:** Person öffnet das Cockpit (oder folgt einem Reminder-Link). Die Puls-Seite zeigt prominent oben „Du hast heute noch nicht gepingt." mit einem großen primären Button. Die Person ist in 3 Klicks im Einstempeln-Flow. **Failure-Mode:** Diesen Hinweis übersehen oder das Formular träge bedienen — die Streak wird gefährdet.
|
||||
2. **Reflexions-Moment am Abend:** Ab 17:00 Uhr erscheint im Header ein dezenter Hinweis „Du hast heute noch nicht reflektiert." Die Person klickt, sieht die eigene ToDo-Liste vom Morgen wieder, hakt ab, schreibt 2–3 Sätze, setzt Ampel. **Failure-Mode:** Die Reflexion fühlt sich wie ein Bürokratie-Akt an — dann wird sie ausgelassen und der Tag bleibt „halb".
|
||||
3. **Jourfixe-Aufruf am Dienstag:** PM öffnet Puls, scrollt einmal von oben nach unten, hat in 60 Sekunden Übersicht über alle Bereiche, Blocker und Pflicht-Minimum-Verstöße. **Failure-Mode:** Wenn Informationen verstreut auf mehreren Tabs liegen, wird die Jourfixe ineffizient.
|
||||
4. **Nächster-Morgen-Reveal:** Person öffnet das Cockpit am Tag X+1, sieht im Area-Feed die konsolidierten Vortags-Einträge der Kollegen — ToDos, abgehakte Status, Reflexion, Ampel. Das schafft das Gefühl gemeinsamer Sichtbarkeit. **Failure-Mode:** Wenn die Publikation um 00:05 fehlschlägt (Cron-Job), erscheint der Tag X+1 leer — Vertrauen ins Cockpit erodiert.
|
||||
5. **Pflicht-Minimum-Erstmal-Sichtbar-Werden:** Bei Projekt-Neuanlage wird in der Projekt-Detail-Sicht die 6-Punkte-Checkliste angezeigt; offene Punkte sind sofort visuell als „nicht erfüllt" markiert. **Failure-Mode:** Wenn die Checkliste versteckt in einem Tab liegt, vergessen Verantwortliche, sie zu pflegen — der zentrale Leitfaden-Hebel verschwindet.
|
||||
|
||||
### Experience Principles
|
||||
|
||||
1. **Disziplin durch Sichtbarkeit, nicht durch Reibung.** Das Cockpit erzwingt nichts mit roten Bannern oder Pflicht-Pop-ups. Es macht nur sichtbar, wer und welches Projekt fehlt. Sichtbarkeit ist der Hebel, nicht Druck.
|
||||
2. **Privat tagsüber, öffentlich rückblickend.** „Mein heute" gehört der Person; der konsolidierte Vortagseintrag gehört dem Team. Diese Zeit-Asymmetrie ist UI-konstitutiv.
|
||||
3. **Monochrome Ruhe, semantische Klarheit.** Weiß und Schwarz dominieren. Farbe (Grün/Gelb/Rot) trägt ausschließlich Bedeutung — niemals Dekoration.
|
||||
4. **Drei-Klick-Maximum für Schreib-Touchpoints.** Einstempeln, Reflexion, Neuer Blocker sind von der Puls-Seite in ≤ 3 Klicks erreichbar (PRD NFR-9).
|
||||
5. **Voice statt Decoration.** „Stark.", „Wie läuft es?", „Du hast heute noch nicht gepingt." als Mikrocopy-Anker. Kein Corporate-Sprech, keine Emojis im UI-Text, keine englischen Floskeln.
|
||||
6. **Die Wahrheit lebt woanders.** Steckbrief in Outline, Issues in Plane, Tickets nicht im Cockpit. Verlinkungen sind eine UI-Erstklass-Komponente, nicht ein technischer Hinweis.
|
||||
7. **Geschwindigkeit ist ein Feature.** Puls < 1,5 s, Update-Speichern < 500 ms. Performance-Wahrnehmung ist Teil des UX-Versprechens — träge wirkt wie respektlos gegenüber der Zeit der Mitarbeiter.
|
||||
|
||||
---
|
||||
|
||||
## Desired Emotional Response
|
||||
|
||||
### Primary Emotional Goals
|
||||
|
||||
Das Cockpit soll vier Gefühle gleichzeitig erzeugen:
|
||||
|
||||
- **Ruhe.** Beim Öffnen kein Lärm, keine bunten Banner, keine Notifications-Flut. Der erste visuelle Eindruck ist *aufgeräumt*.
|
||||
- **Selbstwirksamkeit.** Nach dem Einstempeln das Gefühl: „Ich weiß, was ich heute will." Nach der Reflexion: „Ich weiß, was ich geschafft habe." Kein Pflichtgefühl, sondern stiller Stolz.
|
||||
- **Mitsehen-Können.** Beim Puls-Scrollen: „Ah, so läuft's bei den anderen — gut, dass ich das ohne Fragen sehe." Geteilte Sichtbarkeit ohne Überwachungsgefühl.
|
||||
- **Frühwarn-Vertrauen.** Wenn ein Projekt rot wird, vertraut man dem Cockpit, dass es das schon Tage vorher angedeutet hätte (gelb → rot Trend). Das Cockpit ist ein verlässlicher Frühwarn-Spiegel, kein Schock-Geber.
|
||||
|
||||
Was das Cockpit **nicht** erzeugen darf:
|
||||
|
||||
- ❌ **Lästergefühl** — das „fehlt heute"-Modul darf sich nicht wie ein Pranger anfühlen (PRD CM3).
|
||||
- ❌ **Hektik** — keine roten Banner, keine Pulsing-Animationen, keine Pop-ups.
|
||||
- ❌ **Bürokratie** — Reflexion soll wie ein Tagesabschluss-Ritual wirken, nicht wie ein Stundenzettel.
|
||||
- ❌ **Spielerei** — keine Konfetti-Animationen für Streaks, keine Gamification-Badges. Subtil bleiben.
|
||||
|
||||
### Emotional Journey Mapping
|
||||
|
||||
Eine typische Werktag-Emotion einer Person (Q1 Entwickler oder Q2 Sales):
|
||||
|
||||
| Moment | Erwartete Emotion | UI-Antwort |
|
||||
|---|---|---|
|
||||
| **Login morgens** | Neugierig, leicht angespannt | Klarer Begrüßungs-Header, „Wie läuft es?" sanft, kein Druck |
|
||||
| **„Du hast heute noch nicht gepingt"** | Erinnerung statt Anklage | Großer freundlicher CTA, keine roten Akzente |
|
||||
| **ToDos schreiben** | Fokussierung | Minimalistisches Formular, eine Spalte, monospaced-Optionen |
|
||||
| **Tagsüber im Cockpit reinschauen** | Schnelles Vergewissern | Puls in unter 60 s gelesen, Streak-Badge sichtbar, kein Drill-Down nötig |
|
||||
| **Reflexion abends** | Ritualisches Abschließen | Geführter Flow: Checkboxen → Freitext → Ampel → fertig. Bestätigung mit „Stark." |
|
||||
| **Morgens X+1: Vortags-Reveal** | Soziale Verbundenheit | Konsolidierte Vortageseinträge im Feed, ruhig präsentiert |
|
||||
| **Wenn ein Projekt gelb wird** | Vorsichtige Wachsamkeit | Subtile Gelb-Akzente, klare Erläuterung („Maßnahmenplan greift") |
|
||||
| **Wenn ein Projekt rot wird** | Handlungsbereitschaft | Klarer roter Akzent, Hinweis „braucht jetzt eine Entscheidung", direkter Link zum Projekt-Detail |
|
||||
| **Wenn man die Streak verliert** | Verständnis, keine Strafe | Streak-Counter geht auf 0 ohne Drama; vorheriger Höchstwert wird im Profil als „bester Lauf" gespeichert |
|
||||
|
||||
### Micro-Emotions
|
||||
|
||||
Diese kleinen Momente machen den Unterschied zwischen „funktional" und „liebenswert":
|
||||
|
||||
- **„Stark."-Bestätigungs-Toast** nach erfolgreicher Reflexion. Drei Buchstaben, schwarzer Pill, 2 Sekunden sichtbar.
|
||||
- **Tagesplan-Echo:** Beim Reflektieren werden die ToDos vom Morgen mit einer leichten Animation eingeblendet — die Person sieht das eigene morgendliche Ich wieder.
|
||||
- **Subtile Ladekontrolle:** Skeleton-Boxen statt Spinner. Wartezeit fühlt sich wie „die App ist schon fast da" an, nicht wie „die App hängt".
|
||||
- **Streak-Tag im Profil:** Eine schlichte Zahl mit dem `^`-Caret als Suffix. „14 ^". Keine Trophäen.
|
||||
- **Reflexions-Erinnerung:** Ab 17:00 erscheint im Header ein dezenter Hinweis-Pill „Reflexion offen". Klick → direkt im Flow. Kein Notification-Lärm.
|
||||
- **Vortags-Reveal:** Beim Laden des Feeds am Morgen X+1 erscheinen die neuen konsolidierten Einträge mit einer kurzen Fade-In-Animation (≤ 200 ms). Markiert visuell „diese sind neu seit gestern".
|
||||
|
||||
### Design Implications
|
||||
|
||||
Die emotionalen Ziele übersetzen sich in folgende UX-Pflichten:
|
||||
|
||||
- **Weißraum großzügig.** Cards mit viel Padding, klare vertikale Rhythmen — keine Information-Density um jeden Preis.
|
||||
- **Typografie als Hauptkommunikator.** Hierarchie über Schriftgrade und Gewichte, nicht über Farben.
|
||||
- **Animationen sparsam und schnell.** Höchstens 200 ms, ease-out, niemals länger oder bouncy. Animation als Bestätigung, nicht als Show.
|
||||
- **Status-Farben nur funktional.** Grün/Gelb/Rot ausschließlich bei Ampel/Trend/Blocker. Keine farbigen Decorative-Akzente sonst.
|
||||
- **Mikrocopy konsequent deutsch und kurz.** Keine englischen Floskeln, keine Höflichkeitsfloskeln („Bitte beachten Sie…").
|
||||
- **Kein Drama bei Fehlern.** Fehler-Toasts dezent, in der unteren rechten Ecke, schwarz mit weißem Text.
|
||||
|
||||
### Emotional Design Principles
|
||||
|
||||
1. **Subtil schlägt laut.** Animationen, Farbakzente und Mikrocopy sind unaufdringlich; nichts ringt um Aufmerksamkeit.
|
||||
2. **Ritual statt Pflicht.** Reflexion fühlt sich wie ein Abschluss-Ritual an, nicht wie ein Formular.
|
||||
3. **Sichtbarkeit ist freundlich, nicht überwachend.** „Heute fehlt"-Anzeige als Information, nicht als Anklage.
|
||||
4. **Geschwindigkeit ist Respekt.** Schnell laden, schnell reagieren — das vermittelt: Wir respektieren deine Zeit.
|
||||
5. **Stille Anerkennung.** Streaks, „Stark."-Toasts, „bester Lauf"-Memory: kleine Anerkennungen, keine Trophäen-Show.
|
||||
|
||||
---
|
||||
|
||||
## UX Pattern Analysis & Inspiration
|
||||
|
||||
### Inspiring Products Analysis
|
||||
|
||||
Vier Produkte dienen als Pattern-Referenzen für das Cockpit. Wir kopieren nicht ihren Look, sondern lernen *welche UX-Muster sich für unsere Probleme bewährt haben*.
|
||||
|
||||
**Linear (linear.app)** — Issue-Tracker für Tech-Teams
|
||||
- **Was übernehmen:** Aufgeräumte, monochrome Listen-Ansichten; Tastatur-Shortcuts als Erstklass-Interaktion; sehr schnelle Mutations-Feedback-Schleifen; Status-Icons als kleine Quadrate statt Text.
|
||||
- **Was nicht:** Linear ist Ticket-Tool — wir sind explizit *kein* Ticket-Tool (PRD Anti-Ziel A1).
|
||||
|
||||
**Linear's Sub-Issue-System** — Mini-Hierarchie für Tagesplan
|
||||
- Übertragbar als Inspiration für die ToDo-Liste: minimalistisches Inline-Editing, Tippen → Enter → nächste Zeile.
|
||||
|
||||
**Notion** (notion.so) — Doku & Wissens-Tool
|
||||
- **Was übernehmen:** Klare Typografie-Hierarchie; reduzierte Farbpalette; Embed-/Verlinkungs-Patterns für externe Tools.
|
||||
- **Was nicht:** Wir sind kein Doku-Tool (Outline-Job). Keine Notion-typischen Floating-Elements oder Slash-Commands.
|
||||
|
||||
**GitHub Projects (Roadmap-Sicht)** — Multi-Projekt-Übersicht
|
||||
- **Was übernehmen:** Tabellen-Sicht über viele Items mit klar erkennbarem Status, Filter-Persistenz, Spalten-Sortierung.
|
||||
- **Was nicht:** GitHub-typische Density (für uns zu viel auf einen Blick) und die Bunt-Tags-Kultur.
|
||||
|
||||
**Apple HIG (Human Interface Guidelines)** — System-Design-Sprache
|
||||
- **Was übernehmen:** Klare Modal-Patterns, Toast-Position, Form-Stil, San-Serif Lesbarkeit, generöser Weißraum.
|
||||
|
||||
**Plane (plane.so)** — das parallel laufende Schwester-Tool
|
||||
- **Was übernehmen:** UX-Konsistenz bei Iconografie und Statusfarben, damit der Wechsel Plane ↔ Cockpit nicht mental zerreisst.
|
||||
- **Was nicht:** Plane's volle Issue-Listen — die spiegeln wir nicht (Anti-Ziel A5).
|
||||
|
||||
### Transferable UX Patterns
|
||||
|
||||
| Pattern | Quelle | Anwendung im Cockpit |
|
||||
|---|---|---|
|
||||
| **Inline-Add-on-Enter** | Linear, Notion | ToDo-Liste: Enter erzeugt neue ToDo-Zeile; Cmd+Enter speichert die ganze Liste. |
|
||||
| **Tastatur-Shortcuts mit `?`-Help** | Linear, GitHub | `g p` → Puls, `g h` → Mein heute, `g a` → Archiv. `?` öffnet Cheatsheet. |
|
||||
| **Optimistic Updates mit zarter Bestätigung** | Linear | Beim Abhaken eines ToDos: sofortige UI-Reaktion + leichtes Häkchen-Fade-In; Rollback bei API-Fehler. |
|
||||
| **Skeleton-Cards** | Linear, Vercel | Statt Spinner: graue Card-Skelette mit identischem Layout wie die Echtdaten. |
|
||||
| **Hover-Reveal-Actions** | Linear | Sekundäre Aktionen (Edit, Verwerfen, Löschen) erst beim Hover/Focus sichtbar. Reduziert visuelle Last. |
|
||||
| **Linkurations-Cards** | Notion, Slack-Unfurls | Beim Verlinken einer Plane-URL erscheint im Update eine kleine Card mit Titel und Status (Phase 2 mit API; im MVP: nur URL). |
|
||||
| **Sticky-Sub-Header** | GitHub Projects | Bei langer Bereichs-Detail-View bleibt der Bereichs-Header oben sticky, damit man immer weiß wo man ist. |
|
||||
| **Status-Pill mit Trend-Pfeil** | GitHub PR-Status | Ampel-Pill mit anhängendem Trend-Pfeil als kompaktes Statussignal an Projekt-Karten. |
|
||||
| **Profil-Streak-Stat** | Duolingo (abstrahiert) | Streak als schlichte Zahl im Profil, ohne Streak-Recovery-Käufe oder Drama. |
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
Diese Patterns sind im Cockpit **verboten**, weil sie den Experience Principles widersprechen:
|
||||
|
||||
- ❌ **Roter Mahn-Banner oben.** Statt „Du hast heute noch nicht eingestempelt"-Banner: ein freundlicher CTA mit `^`-Caret und schwarzem Pill-Button. Niemals Alarmrot.
|
||||
- ❌ **Notification-Badges mit Zahlen.** Wir zeigen Status, nicht Posteingänge. Keine roten Zähler.
|
||||
- ❌ **Gamification-Trophäen.** Keine Badges, keine Levels, keine Konfetti-Animationen. Streak ist genug.
|
||||
- ❌ **Pflicht-Pop-up beim Login** („Bitte stempel zuerst ein"). Stattdessen ist der Einstempel-CTA Teil der Puls-Seite, nicht ein Modal.
|
||||
- ❌ **Bunte Status-Tags** in Marketing-/Vertrieb-Stil. Status-Farben bleiben monochrom + 3 Ampel-Töne.
|
||||
- ❌ **Komplexe Filter-Sidebar**. Im MVP keine Multi-Facet-Filter. Wenn der Feed lang wird: Bereich-Filter + „nur meine" reicht.
|
||||
- ❌ **Dichte Tabellen mit 10+ Spalten.** Karten/Lists, keine Excel-Sheets.
|
||||
- ❌ **Pulsing-/Spin-Animationen** auf der Puls-Seite. Animation nur bei expliziter User-Aktion.
|
||||
- ❌ **Auto-Refresh**. Daten werden bei Page-Navigation und manuellem Pull-to-Refresh-Equivalent neu geholt. Kein Background-Polling außer am User-aktiven Tab.
|
||||
- ❌ **Drag-and-Drop überall**. Drag-and-Drop nur im Sales-Kanban (FR-12), nicht für ToDos oder Meilensteine.
|
||||
|
||||
### Design Inspiration Strategy
|
||||
|
||||
- **Atomare Inspiration, nicht Holistik.** Wir kopieren keine ganzen App-Looks; wir übernehmen einzelne Patterns (Inline-Add, Skeleton, Sticky-Sub-Header) und kombinieren sie mit der ToolCTRL-Brand.
|
||||
- **Brand schlägt Pattern.** Wo ein Pattern (z. B. Notion-Slash-Commands) gegen die Brand-Reduktion arbeiten würde, lassen wir es weg.
|
||||
- **Linear ist die nächste Konstante.** Im Zweifel folgen wir Linear-Patterns für Listenwesen und Mutations-Feedback, da Linear das am saubersten löst für Tech-Teams.
|
||||
- **Eigene Patterns nur wo nötig.** Sichtbarkeitsmodell pre/post Publikation und Pflicht-Minimum-Panel sind eigene Erfindungen — die müssen wir selbst gut machen.
|
||||
|
||||
---
|
||||
|
||||
## Design System Foundation
|
||||
|
||||
### Design System Choice
|
||||
|
||||
**Tailwind CSS** (vom Architektur-Starter gesetzt) als Utility-First-Foundation, ergänzt um **Radix UI Primitives** für interaktive Komponenten (Dialog, Dropdown, Tabs, Tooltip) und eine **schlanke Custom-Component-Library** in `apps/web/src/components/ui/`.
|
||||
|
||||
**Keine vollständige Component-Library** (kein shadcn-Bulk-Install, kein Material-UI, kein Ant Design): Die Brand ist zu spezifisch und das Tooling-Volumen zu klein, um eine externe Library zu rechtfertigen.
|
||||
|
||||
### Rationale for Selection
|
||||
|
||||
- **Tailwind passt zur Brand:** Monochrome Palette, klare Spacing-Skala, kein Default-Theme das mit der Brand kämpft.
|
||||
- **Radix UI Primitives liefern Accessibility-Korrektheit** ohne Visual-Design — wir styleen sie mit Tailwind nach unserer Sprache. Spart Bug-Klassen wie kaputte Focus-Traps in Modalen, falsche ARIA-Roles, etc.
|
||||
- **Eigene Components statt shadcn:** shadcn copies kleine Library-Snippets ins Repo — bei unserer Größe ist es kontrollierbarer, ~15 Komponenten direkt im Brand-Stil zu schreiben, als 50 Snippets zu kopieren und Brand-anzupassen.
|
||||
- **Keine Dark-Mode-Pflicht im MVP:** Das Cockpit ist Single-Theme (weiß primär). Phase 2 kann Dark Mode hinzufügen.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
```
|
||||
apps/web/src/components/ui/ # Eigene Brand-konforme Komponenten
|
||||
├── Button.tsx # 3 Varianten: primary, secondary, ghost
|
||||
├── Card.tsx # Standard-Container mit großen Radii
|
||||
├── Input.tsx # Text + Number + URL
|
||||
├── Textarea.tsx
|
||||
├── Select.tsx # nutzt Radix Select
|
||||
├── Checkbox.tsx # nutzt Radix Checkbox
|
||||
├── Toggle.tsx # 3-Wege-Toggle (Ampel)
|
||||
├── Dialog.tsx # nutzt Radix Dialog
|
||||
├── Tabs.tsx # nutzt Radix Tabs
|
||||
├── Tooltip.tsx # nutzt Radix Tooltip
|
||||
├── Toast.tsx # nutzt Sonner (toast lib)
|
||||
├── SkeletonCard.tsx
|
||||
├── AmpelBadge.tsx # Status-Pill mit Trend
|
||||
├── PflichtMinimumIndicator.tsx # 6-Punkt-Block-Indikator
|
||||
└── KeyCaret.tsx # Das `^`-Brand-Element
|
||||
```
|
||||
|
||||
### Customization Strategy
|
||||
|
||||
1. **Brand-Tokens als CSS-Variablen im `:root`**, von Tailwind via Theme-Erweiterung konsumiert. Das erlaubt späteres Dark-Mode-Switching ohne Class-Renaming.
|
||||
2. **Eine `tailwind.config.ts` als Source of Truth** für Spacing, Radii, Schriftgrade, Farben. Custom-Components verwenden `@apply`-Composition oder direkte Utility-Klassen.
|
||||
3. **Radix-Komponenten werden gestyled, nicht überschrieben.** Tailwind-Classes auf Radix-Primitives (`<Dialog.Content className="…">`).
|
||||
4. **Icon-Set:** **Lucide-React** als Default (offen, kostenlos, konsistent). Wo Brand-Icons aus `ToolCTRL Brand Identity/Brand EcoSystem/Ui icons Pattern` vorhanden sind (Home, Settings, Search, Trash, Zoom-in, etc.) — bevorzugt verwenden; Rest aus Lucide. Beide Sets sind monochrom-filled.
|
||||
|
||||
---
|
||||
|
||||
## Defining Experience (Detail)
|
||||
|
||||
### Defining Experience
|
||||
|
||||
Im Zentrum steht der **Tagesrhythmus-Doppeltakt**: ein zeitlich getrennter Schreib-Workflow (morgens setzen, abends reflektieren), der bewusst nicht als ein einziges Formular vereint wird, sondern als zwei eigene Touchpoints existiert — getrennt durch den Tag. Die Trennung ist kein technischer Workaround, sondern das UX-Versprechen: „Plan und Realität dürfen sich unterscheiden, und wir machen genau diese Differenz sichtbar."
|
||||
|
||||
Die zweite Erfahrungs-Säule ist der **Puls als gemeinsamer Spiegel**: ein einziger, dichter Bildschirm, der jeden Werktagsmorgen die Antwort liefert auf „Wie steht es heute um uns alle?".
|
||||
|
||||
### User Mental Model
|
||||
|
||||
Die Personen sollen das Cockpit so verstehen:
|
||||
|
||||
- **„Mein heute" ist mein Privates** — wie eine Tageskarte, die nur ich sehe.
|
||||
- **Der Puls ist unser Gemeinsames** — wie ein Schwarzes Brett, das jeden Werktag aktuell ist.
|
||||
- **Bereich = Familie meines Projekts/Deals/Kampagne** — Entwicklung, Vertrieb, Marketing als drei eigene Häuser unter einem Dach.
|
||||
- **Plane ist Werkzeug, Outline ist Wissen, Cockpit ist Spiegel.** Das Cockpit interpretiert oder editiert nichts — es zeigt.
|
||||
|
||||
Was das Modell **vermeiden** muss: die Verwechslung „das Cockpit ist mein Ticket-Tool" (es ist nicht Plane) oder „das Cockpit ist mein Wiki" (es ist nicht Outline).
|
||||
|
||||
### Success Criteria
|
||||
|
||||
Die Kern-Erfahrung gilt als erfolgreich, wenn nach 8 Wochen Live-Betrieb gilt:
|
||||
|
||||
- ≥ 80 % der Werktage haben pro aktiver Person mindestens ein abgeschlossenes Tagesupdate (PRD M1).
|
||||
- Median Einstempel-Zeit ≤ 60 s, Median Reflexions-Zeit ≤ 90 s (CM1a/CM1b).
|
||||
- 100 % der Jourfixes nutzen den Puls als Hauptbildschirm (M4).
|
||||
- Kein erhöhter Druck-/Lästergefühl im 4-Wochen-Retro (CM3).
|
||||
|
||||
### Novel UX Patterns
|
||||
|
||||
Diese Patterns sind im Cockpit **neu erfunden** (haben kein direktes Vorbild in der Inspirations-Bibliothek):
|
||||
|
||||
1. **Sichtbarkeits-Asymmetrie über Zeit:** ToDos sind tagsüber privat, am nächsten Werktagsmorgen publiziert — bewusst keine Realtime-Sichtbarkeit. Visuell muss klar sein: „Was du gerade tippst, sehen Kollegen ab morgen."
|
||||
2. **Pflicht-Minimum-Indikator:** Sechs kleine Blöcke (•••••• statt einzelner Häkchen-Checkliste) zeigen pro Projekt den Erfüllungsstand auf einen Blick. Ein klick zum Detail.
|
||||
3. **Tagesplan-Echo:** Bei der Abend-Reflexion erscheinen die morgens gesetzten ToDos zurück — Person konfrontiert sich mit dem eigenen morgendlichen Plan.
|
||||
4. **Tagesstatus-Pill pro Person:** 🟢 voll, 🟡 halb, ⚪ nicht angefangen, 🟣 abwesend — eine vierwertige Statusanzeige, die sich klar von Projekt-Ampeln (3-wertig + grau) unterscheidet.
|
||||
|
||||
### Experience Mechanics
|
||||
|
||||
**Morgen-Einstempeln-Flow:**
|
||||
|
||||
1. Person öffnet Cockpit → Puls-Seite.
|
||||
2. CTA „Einstempeln" prominent oberhalb des Folds. Bei nicht-eingestempelten Personen großer schwarzer Pill-Button mit `^`-Caret.
|
||||
3. Klick öffnet `/mein-heute` (kein Modal — eigene Route, das `^` der Brand wird Teil des Headers).
|
||||
4. Eine zentrale Spalte: leere ToDo-Liste mit Cursor auf Zeile 1.
|
||||
5. Bereich ist vorausgewählt (Default = eigene Area). Bezugs-Auswahl pro ToDo erscheint inline rechts neben der Zeile, wenn die ToDo fokussiert ist (Hover-Reveal-Pattern).
|
||||
6. Enter erzeugt nächste Zeile. Cmd/Ctrl+S oder Klick auf „Eingestempelt"-Button speichert.
|
||||
7. Bestätigung: Subtiler „Eingestempelt."-Toast (2 s) + Redirect auf Puls. „Heute fehlt"-Modul auf Puls zeigt eine Person weniger.
|
||||
|
||||
**Abend-Reflexions-Flow:**
|
||||
|
||||
1. Ab 17:00 erscheint im Header-Bereich ein dezenter „Reflexion offen"-Pill.
|
||||
2. Klick → Route `/reflexion` (oder `/mein-heute` mit Reflexions-Tab).
|
||||
3. Person sieht die morgens definierte ToDo-Liste, Klick auf Checkbox-Icon hakt ab, Klick auf X-Icon verwirft (mit optionalem Grund im Inline-Field).
|
||||
4. Unter der ToDo-Liste: ein Freitext-Bereich „Sonst noch was?" (1.000 Zeichen, monospaced-Counter zeigt verbleibende Zeichen unauffällig).
|
||||
5. Drei große farbige Ampel-Buttons (Grün/Gelb/Rot), darunter drei Trend-Pfeile (Pflicht, wenn Bezug=Projekt).
|
||||
6. Optionaler „Blocker?"-Toggle, öffnet ein Inline-Feld.
|
||||
7. „Reflektiert."-Toast (2 s) + Redirect auf Puls.
|
||||
|
||||
**Vortags-Reveal:**
|
||||
|
||||
- Beim ersten Laden des Puls am Werktag X+1 erscheinen die konsolidierten Vortageseinträge im Feed mit kurzem Fade-In. Header-Hinweis (sehr dezent): „Stand: Mo, 24. Mai".
|
||||
|
||||
---
|
||||
|
||||
## Visual Design Foundation
|
||||
|
||||
### Color System
|
||||
|
||||
Konkrete Brand-Tokens, abgeleitet aus der ToolCTRL Brand Identity. Werden in `apps/web/src/styles/globals.css` als CSS-Variablen definiert und in `tailwind.config.ts` als Theme-Erweiterung gespiegelt.
|
||||
|
||||
**Neutrals (Hauptpalette):**
|
||||
|
||||
| Token | Hex | Verwendung |
|
||||
|---|---|---|
|
||||
| `--color-bg` | `#ffffff` | App-Hintergrund |
|
||||
| `--color-surface` | `#fafafa` | Card-Hintergrund (sehr subtil von BG getrennt) |
|
||||
| `--color-surface-elevated` | `#ffffff` | Modal-Hintergrund, schwebende Elemente (mit Schatten) |
|
||||
| `--color-border` | `#e5e5e5` | Dünne Trennlinien, Card-Borders |
|
||||
| `--color-border-strong` | `#d4d4d4` | Hover-Border, fokussierter Trennstrich |
|
||||
| `--color-ink` | `#0a0a0a` | Haupttext (near-black, nicht reines #000 für weichere Lesbarkeit) |
|
||||
| `--color-ink-muted` | `#525252` | Sekundärtext, Labels |
|
||||
| `--color-ink-faint` | `#a3a3a3` | Tertiärtext, Platzhalter |
|
||||
| `--color-brand` | `#0a0a0a` | Primärbutton-Hintergrund, Marken-Akzente (= ink) |
|
||||
| `--color-brand-fg` | `#ffffff` | Text auf Brand-Flächen |
|
||||
|
||||
**Semantische Ampel-Farben** (auch hell genug für die Brand-Welt):
|
||||
|
||||
| Token | Hex (Foreground/Text) | Hex (Background/Surface) | Verwendung |
|
||||
|---|---|---|---|
|
||||
| `--color-ampel-green` | `#15803d` | `#dcfce7` | Status „Im Plan" |
|
||||
| `--color-ampel-amber` | `#a16207` | `#fef3c7` | Status „Gefährdet" |
|
||||
| `--color-ampel-red` | `#b91c1c` | `#fee2e2` | Status „Blockiert" |
|
||||
|
||||
**Trend-Indikatoren:**
|
||||
|
||||
| Token | Hex |
|
||||
|---|---|
|
||||
| `--color-trend-up` | `#15803d` (besser, ↗︎) |
|
||||
| `--color-trend-flat` | `#525252` (gleich, →) |
|
||||
| `--color-trend-down` | `#b91c1c` (schlechter, ↘︎) |
|
||||
|
||||
**Focus / Interaction:**
|
||||
|
||||
| Token | Hex | Verwendung |
|
||||
|---|---|---|
|
||||
| `--color-focus-ring` | `#0a0a0a` mit 20% opacity outline | Tastatur-Fokus-Indikator |
|
||||
| `--color-hover-overlay` | `rgba(0,0,0,0.04)` | Hover-Layer auf Cards/Buttons |
|
||||
|
||||
### Typography System
|
||||
|
||||
**Schriftfamilie:**
|
||||
- **Sans-Serif (UI):** `Inter` — selbst-gehostet via `@fontsource/inter`. Variable Font (300–900). Lehnt am Brand-Marketing-Sans an (geometrische Sans-Serif).
|
||||
- **Monospace (Werte/Zeiten):** `JetBrains Mono` — selbst-gehostet via `@fontsource/jetbrains-mono`. Für Zeitstempel, EUR-Beträge, Datums-Anzeigen.
|
||||
|
||||
**Type-Scale** (basierend auf 16 px Body):
|
||||
|
||||
| Token | Größe / Line-Height / Weight | Verwendung |
|
||||
|---|---|---|
|
||||
| `text-xs` | 12 / 16 / 500 | Labels, Captions, Audit-Zeitstempel |
|
||||
| `text-sm` | 14 / 20 / 400 | Sekundärtext, Meta-Information |
|
||||
| `text-base` | 16 / 24 / 400 | Body, Form-Inputs |
|
||||
| `text-lg` | 18 / 28 / 500 | Card-Titel |
|
||||
| `text-xl` | 20 / 28 / 600 | Sektions-Header |
|
||||
| `text-2xl` | 24 / 32 / 700 | Seitentitel |
|
||||
| `text-3xl` | 32 / 40 / 700 | Hero im Puls („Stand: Di, 25. Mai") |
|
||||
|
||||
**Voice-Anker-Typografie:**
|
||||
- „**Stark.**" (Toast): `text-base font-semibold` schwarzer Pill.
|
||||
- „**Wie läuft es?**" (Reflexions-Header): `text-xl font-medium ink-muted`.
|
||||
- „**Du hast heute noch nicht gepingt.**" (Puls-CTA-Banner): `text-lg font-medium ink` + Button daneben.
|
||||
|
||||
### Spacing & Layout Foundation
|
||||
|
||||
**Spacing-Skala** (Tailwind-Standard `0.25rem` = `4px`):
|
||||
|
||||
| Token | Wert | Anwendung |
|
||||
|---|---|---|
|
||||
| `space-1` | 4 px | Mikro-Padding |
|
||||
| `space-2` | 8 px | Innen-Padding kleiner Pills |
|
||||
| `space-3` | 12 px | Standard-Gap zwischen Inline-Elementen |
|
||||
| `space-4` | 16 px | Card-Inner-Padding small |
|
||||
| `space-6` | 24 px | Card-Inner-Padding default |
|
||||
| `space-8` | 32 px | Sektions-Abstand |
|
||||
| `space-12` | 48 px | Seitenrand-Padding |
|
||||
| `space-16` | 64 px | Hero-Abstand |
|
||||
|
||||
**Radii:**
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `radius-sm` | 6 px | kleine Pills, Inputs |
|
||||
| `radius-md` | 10 px | Buttons, Tags |
|
||||
| `radius-lg` | 16 px | Cards, Modal-Container |
|
||||
| `radius-xl` | 24 px | Hero-Cards, Brand-konforme Big-Rounded-Boxes (vgl. Need-Help-PDF Brand-Stil) |
|
||||
| `radius-full` | 9999 px | Pills, Avatars |
|
||||
|
||||
**Schatten (sehr subtil, near-black mit niedriger Opacity):**
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `shadow-sm` | `0 1px 2px rgba(10,10,10,0.04)` | Cards in Default-State |
|
||||
| `shadow-md` | `0 4px 12px rgba(10,10,10,0.06)` | Hover-State Cards |
|
||||
| `shadow-lg` | `0 12px 32px rgba(10,10,10,0.10)` | Modale, Dropdowns |
|
||||
|
||||
**Grid & Container:**
|
||||
|
||||
- **Container-Max-Width:** 1.280 px, horizontal zentriert.
|
||||
- **Container-Padding-X:** 24 px (mobile) → 48 px (≥ md).
|
||||
- **Grid:** 12-Spalten-Grid mit 24 px Gap auf Desktop.
|
||||
- **Puls-Layout:** 3-Spalten-Grid für die drei Bereiche; auf Tablet 2-Spalten; auf Mobile single-column-Stack.
|
||||
|
||||
### Accessibility Considerations
|
||||
|
||||
- **WCAG 2.1 AA** als Mindeststandard für Kontraste:
|
||||
- `--color-ink` auf `--color-bg`: 19.5:1 ✅
|
||||
- `--color-ink-muted` auf `--color-bg`: 8.1:1 ✅
|
||||
- `--color-ampel-green` Text auf Green-BG: 5.5:1 ✅
|
||||
- `--color-ampel-amber` Text auf Amber-BG: 5.8:1 ✅
|
||||
- `--color-ampel-red` Text auf Red-BG: 6.1:1 ✅
|
||||
- **Farbe ist nie alleinige Information.** Ampel hat zusätzlich Icon + Text-Label („Im Plan", „Gefährdet", „Blockiert"). Trend hat Pfeil-Icon zusätzlich zur Farbe.
|
||||
- **Fokus-Indikator** auf jedem interaktiven Element — `outline: 2px solid var(--color-ink)` mit 2 px Offset.
|
||||
- **Tastatur-Navigation:** Tab-Order folgt visueller Reihenfolge; alle interaktiven Elemente sind ohne Maus erreichbar.
|
||||
- **Screenreader:** Radix-Primitives liefern korrekte ARIA-Roles automatisch. Custom-Components ergänzen `aria-label` wo nötig (z. B. Icon-Only-Buttons).
|
||||
- **Reduzierte Bewegung:** `prefers-reduced-motion: reduce` schaltet Fade-In-Animationen auf 0 ms.
|
||||
- **Schriftgrad-Mindest:** keine Schriftgröße kleiner als 12 px im UI.
|
||||
|
||||
---
|
||||
|
||||
## Design Direction Decision
|
||||
|
||||
### Design Directions Explored
|
||||
|
||||
Drei Visualrichtungen wurden konzeptionell erwogen:
|
||||
|
||||
1. **"Office Minimal" (gewählt)** — fast vollständig weiß-grau-schwarz, Typografie-getragen, einzelne semantische Farbakzente, sehr ruhig.
|
||||
2. **"Brand Dark Hero"** — schwarzer Header und Hero-Bereich wie in den LinkedIn-Posts; Body auf Weiß. Dramatischer, marken-näher.
|
||||
3. **"Colorful Status Pop"** — Brand monochrom, aber Ampeln und Bereichs-Akzente sehr farbig und groß. Spielerischer Versuch.
|
||||
|
||||
### Chosen Direction
|
||||
|
||||
**Office Minimal** — die Anwendung präsentiert sich als ruhiges, fast nüchternes Werkzeug, das durch Klarheit überzeugt, nicht durch visuelle Effekte.
|
||||
|
||||
### Design Rationale
|
||||
|
||||
- **Passt zum User-Kontext:** Internes Tool, das mehrmals täglich geöffnet wird. „Brand Dark Hero" ermüdet auf Dauer, „Colorful Status Pop" wird laut.
|
||||
- **Passt zur Brand-Reduktion:** ToolCTRL ist visuell minimal — Office Minimal überträgt diese Reduktion in die App.
|
||||
- **Diensthandelnde Klarheit:** Wer schnell wissen will, „läuft das Projekt?", soll die Antwort in 1 s lesen können — das geht am besten mit hohem Kontrast und wenig Visual-Noise.
|
||||
- **Brand-Akzente bleiben übrig:** Der `^`-Caret als wiederkehrendes Element, der schwarze Pill-Button-Stil, große Radii — das bringt die Marken-DNA in die App, ohne sie aufzudrängen.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
- Weiß als Default-Background überall.
|
||||
- Cards mit `radius-lg` (16 px), `shadow-sm`, `border: 1px solid var(--color-border)`.
|
||||
- Primärbuttons schwarz mit weißem Text + `radius-md` (10 px).
|
||||
- Sekundärbuttons mit transparentem Hintergrund + 1 px Border in `--color-ink`.
|
||||
- Ghost-Buttons (z. B. „Abbrechen") ganz ohne Border, nur Text-Color.
|
||||
- Ampel-Indikatoren als kleine farbige Punkte/Pills mit kontrastreichem Text auf zarter Background-Farbe.
|
||||
- Brand-Element `^` als kleines Detail im Logo-Lockup oben links: `cockpit ^`.
|
||||
- Hero-Texte (z. B. „Stand: Di, 25. Mai") in `text-3xl font-bold` schwarz auf weiß.
|
||||
|
||||
---
|
||||
|
||||
## User Journey Flows
|
||||
|
||||
### UJ-1 — Tagesrhythmus (Morgen → Abend → Nächster Morgen)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Login via Authentik] --> B[Puls-Seite]
|
||||
B --> C{Schon eingestempelt?}
|
||||
C -- nein --> D[CTA Einstempeln klicken]
|
||||
D --> E[/mein-heute öffnet sich]
|
||||
E --> F[ToDos tippen, Enter erzeugt neue Zeile]
|
||||
F --> G[Eingestempelt-Button]
|
||||
G --> H[Stark.-Toast 2s]
|
||||
H --> B
|
||||
C -- ja --> I[Tagsüber arbeiten / weitere Cockpit-Lookups]
|
||||
I --> J{Nach 17:00?}
|
||||
J -- nein --> I
|
||||
J -- ja --> K[Reflexion-offen-Pill im Header]
|
||||
K --> L[/reflexion oder /mein-heute Tab Reflexion]
|
||||
L --> M[ToDos abhaken / verwerfen]
|
||||
M --> N[Freitext was sonst noch]
|
||||
N --> O[Ampel + Trend setzen]
|
||||
O --> P{Blocker?}
|
||||
P -- ja --> Q[Blocker-Text inline]
|
||||
P -- nein --> R[Reflektieren-Button]
|
||||
Q --> R
|
||||
R --> S[Reflektiert.-Toast]
|
||||
S --> B
|
||||
B --> T[Nächster Werktag 00:05: Cron publiziert]
|
||||
T --> U[Vortrags-Reveal im Feed mit Fade-In]
|
||||
```
|
||||
|
||||
**Touchpoints im Detail:**
|
||||
|
||||
- **Morgen (CM1a ≤ 60 s):** Person + Bereich (Default) + 3-5 ToDos + Submit. UI muss Tab-Reihenfolge perfekt einhalten — Erste ToDo-Zeile bekommt sofort Fokus.
|
||||
- **Tagsüber:** Cockpit darf aufgerufen werden, aber zeigt der Person nur die eigene Sicht + den Puls; die ToDos sind nur für sie selbst sichtbar.
|
||||
- **Abend (CM1b ≤ 90 s):** Tagesplan-Echo (ToDos vom Morgen erscheinen mit kurzem Fade-In), schnelles Abhaken via Klick, ein bis drei Sätze Freitext, drei große Buttons für Ampel, drei Pfeile für Trend, fertig.
|
||||
- **Nächster Morgen:** Vortrags-Reveal als zarte UI-Akzentuierung; sonst bleibt das Cockpit ruhig.
|
||||
|
||||
### UJ-2 — Jourfixe-Moderation (Di 11:00, P1/P4)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[PM öffnet Cockpit am Di 11:00] --> B[Puls-Seite]
|
||||
B --> C[Bereichs-Strip lesen: 3 Ampelstatus]
|
||||
C --> D[Blocker-Liste lesen]
|
||||
D --> E[Pflicht-Minimum-Übersicht lesen]
|
||||
E --> F{Rote oder gelbe Projekte?}
|
||||
F -- ja --> G[Klick auf rotes/gelbes Projekt]
|
||||
G --> H[Projekt-Detail mit Updates + Meilensteinen]
|
||||
H --> I[Diskussion mit Verantwortlichem]
|
||||
I --> J{Weitere Projekte?}
|
||||
J -- ja --> G
|
||||
J -- nein --> K[Optional: Jourfixe-Meta-Update als allgemeines Update]
|
||||
F -- nein --> K
|
||||
K --> L[Jourfixe beendet]
|
||||
```
|
||||
|
||||
### UJ-3 — Verfall früh erkennen (P1, P4)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Person scrollt durch Puls] --> B[Projekt mit rotem Status auffällt]
|
||||
B --> C[Klick auf Projekt-Card]
|
||||
C --> D[Projekt-Detail zeigt: letzter Update vor X Tagen]
|
||||
D --> E[Letzte Reflexionen mit Trend ↘︎]
|
||||
E --> F[Meilenstein-Status: X von Y überzogen]
|
||||
F --> G[Pflicht-Minimum-Panel zeigt unerfüllte Punkte]
|
||||
G --> H[Person schreibt allgemeines Update mit Bezug auf Projekt]
|
||||
```
|
||||
|
||||
### UJ-4 — Onboarding eines neuen Mitarbeiters
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Admin legt User in Authentik an + Group] --> B[User loggt sich erstmals ein]
|
||||
B --> C[OIDC-Flow läuft, Backend legt User-Zeile an]
|
||||
C --> D[Default-Bereich aus Authentik-Group gemappt]
|
||||
D --> E[Puls-Seite: Hero-Hinweis Erstes Tagesupdate]
|
||||
E --> F[User klickt Einstempeln]
|
||||
F --> G[Schreibt erste ToDos]
|
||||
G --> H[Erscheint in Anwesenheits- + Streak-Liste, Streak=1]
|
||||
```
|
||||
|
||||
### UJ-5 — Projekt im Cockpit-Sicht anlegen (P1)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[P1 legt Steckbrief in Outline an] --> B[Wechsel ins Cockpit]
|
||||
B --> C[Bereichs-Detail → Neues Projekt]
|
||||
C --> D[Modal: Name, Verantwortliche/r, Projektart, Arbeitsmodus]
|
||||
D --> E[Felder: Steckbrief-URL Pflicht, Plane-Board-URL Pflicht]
|
||||
E --> F[Optional: DoD-Text, Risikoliste-URL]
|
||||
F --> G[Optional: erste Meilensteine inline]
|
||||
G --> H[Speichern → Projekt erscheint im Bereich + im Puls]
|
||||
H --> I[Pflicht-Minimum-Indikator startet bei 4-6 erfüllten Punkten]
|
||||
```
|
||||
|
||||
### Journey Patterns
|
||||
|
||||
- **„Puls-zurück" als roter Faden:** Nach jedem Schreib-Touchpoint kehrt die Person auf den Puls zurück. Das verankert den Puls als Heimat.
|
||||
- **Toast als kleinster Erfolg:** Jede Mutation → kurze Toast-Bestätigung mit Voice-Anker (`Stark.`, `Reflektiert.`, `Blocker offen.`).
|
||||
- **Inline-Eingabe statt Modal-Stapel:** Wo möglich, werden Bezüge inline gesetzt, nicht in einem zweiten Modal. Modals nur für Projekt-Anlage und Blocker-Auflösung.
|
||||
- **Breadcrumb über Tabs:** Statt Tabs nutzen wir Breadcrumbs (Puls → Bereich → Projekt) für sauberere Navigation und einfachere Tastatursteuerung.
|
||||
|
||||
### Flow Optimization Principles
|
||||
|
||||
1. **First-Field-Focus.** Jedes Modal/jeder Schreib-Flow setzt den Cursor automatisch auf das erste sinnvolle Feld.
|
||||
2. **Enter ist Submit, wo es Sinn ergibt.** ToDo-Liste: Enter → neue Zeile. Reflexion: Cmd/Ctrl+Enter → Submit.
|
||||
3. **Esc schließt Modale ohne Speichern** (Standard für alle Radix-Dialoge).
|
||||
4. **Keine Multi-Step-Wizards.** Selbst die Reflexion ist ein single-screen Flow mit gestaffeltem visuellem Aufbau, nicht „Schritt 1 von 3".
|
||||
5. **Konsequente Rückkehr.** Nach Erfolg: zurück zum Puls. Nach Abbruch: zurück zur Vor-Seite via Browser-History (Esc + Back).
|
||||
|
||||
---
|
||||
|
||||
## Component Strategy
|
||||
|
||||
### Design System Components
|
||||
|
||||
Aus dem Tailwind + Radix-Stack stehen folgende Primitives ohne Eigenentwicklung zur Verfügung:
|
||||
|
||||
- **Radix UI:** Dialog, Dropdown Menu, Select, Checkbox, Tabs, Toast (via Sonner), Tooltip, Popover, Hover Card, Collapsible, Accordion, Avatar, Form, Label, Toggle, Toggle Group.
|
||||
- **Lucide React:** ~1.500 monochrome Icons (Suche, Settings, Plus, Check, X, ChevronDown, ArrowUpRight, ArrowRight, ArrowDownRight, Calendar, Clock, User, Filter, etc.)
|
||||
- **Sonner:** Toast-Library.
|
||||
- **React Hook Form + Zod:** Form-Management.
|
||||
- **@fontsource/inter + @fontsource/jetbrains-mono:** Selbst-hostbare Brand-Fonts.
|
||||
|
||||
### Custom Components
|
||||
|
||||
15 hauseigene Komponenten in `apps/web/src/components/ui/`:
|
||||
|
||||
| Komponente | Zweck | Verwendung in FRs |
|
||||
|---|---|---|
|
||||
| `Button` | 3 Varianten (primary/secondary/ghost), 3 Größen (sm/md/lg) | Überall |
|
||||
| `Card` | Standard-Container mit `radius-lg`, `shadow-sm`, `border` | Puls, Bereiche, Projekte, Listen |
|
||||
| `Input` | Text-Input mit Brand-Style | FR-21, FR-22, FR-26 |
|
||||
| `Textarea` | Multi-Line-Eingabe, Counter unten rechts | FR-26 |
|
||||
| `Select` | Auf Radix-Select, kompakter dropdown | FR-22 Bezug, Bereich |
|
||||
| `Checkbox` | Auf Radix-Checkbox, animiertes Häkchen | FR-26 ToDo abhaken |
|
||||
| `AmpelButton` | 3-Wege-Toggle Grün/Gelb/Rot, große Klick-Targets | FR-26 |
|
||||
| `TrendButton` | 3-Wege-Toggle ↗︎/→/↘︎ | FR-26 |
|
||||
| `Dialog` | Auf Radix-Dialog, mit Brand-Header und `radius-xl` | FR-31 Blocker, FR-45 Abschluss |
|
||||
| `Tooltip` | Auf Radix-Tooltip, schwarzer Bubble mit weißem Text | Hover-Erläuterungen |
|
||||
| `Toast` | via Sonner, schwarzer Pill mit weißem Text, Bottom-Right | Voice-Ankerbestätigungen |
|
||||
| `SkeletonCard` | Graue Card-Skelette identischer Geometrie wie echte Cards | Loading-States überall |
|
||||
| `AmpelBadge` | Status-Pill mit Farbpunkt + Label + optionalem Trend-Pfeil | FR-9, FR-21 |
|
||||
| `PflichtMinimumIndicator` | Sechs kleine Quadrate horizontal, gefüllt/leer | FR-9 (Projekt-Card), FR-7 (Puls) |
|
||||
| `KeyCaret` | Brand-`^`-Element als SVG, einbindbar in Header/Logo | TopNav, Toast-Bestätigung |
|
||||
|
||||
### Component Implementation Strategy
|
||||
|
||||
- **Compound-Components statt Prop-Explosion:** `<Card.Root>`, `<Card.Header>`, `<Card.Body>`, `<Card.Footer>` für flexible Layouts ohne 20 Props.
|
||||
- **`cva` (class-variance-authority)** für Variant-Verwaltung (`Button.variants.primary`, `Button.variants.secondary`).
|
||||
- **Storybook entfällt im MVP** — bei 15 Komponenten und einem Frontend-Team können wir per `apps/web/src/routes/dev/components.tsx` eine eigene „Component Gallery"-Seite bauen, die nur in dev-Mode sichtbar ist.
|
||||
- **Snapshot-Tests via Vitest + jsdom** für Komponenten-Stabilität.
|
||||
|
||||
### Implementation Roadmap
|
||||
|
||||
1. **Sprint 1 — UI-Kit-Bootstrap:** Button, Card, Input, Textarea, KeyCaret, Toast, SkeletonCard. Component-Gallery-Route.
|
||||
2. **Sprint 2 — Status-Spezifika:** AmpelButton, TrendButton, AmpelBadge, PflichtMinimumIndicator.
|
||||
3. **Sprint 3 — Interaktion:** Dialog, Tooltip, Select, Checkbox.
|
||||
4. **Sprint 4 — Feature-Komponenten:** Puls-Module, Bereichs-Details, Tagesrhythmus-Flow.
|
||||
5. **Sprint 5 — Spezialfälle:** Sales-Kanban (Drag-and-Drop via dnd-kit), Lead-Funnel.
|
||||
|
||||
---
|
||||
|
||||
## UX Consistency Patterns
|
||||
|
||||
### Button Hierarchy
|
||||
|
||||
| Variant | Verwendung | Visual |
|
||||
|---|---|---|
|
||||
| **Primary** | Hauptaktion einer Seite (Einstempeln, Reflektieren, Speichern) | Schwarzer Hintergrund, weißer Text, `radius-md` |
|
||||
| **Secondary** | Alternative Aktion (Abbrechen mit Aktion, Sekundäre Navigation) | Transparenter Hintergrund, 1 px schwarzer Border, schwarzer Text |
|
||||
| **Ghost** | Tertiär (Abbrechen, Schließen, Edit-Hover-Actions) | Kein Border, kein Hintergrund, nur Text. Hover bekommt `--color-hover-overlay`. |
|
||||
| **Destructive** | Löschen, Archivieren | Wie Primary, aber `--color-ampel-red`-Hintergrund (selten genutzt — Cockpit kennt fast keine Lösch-Aktionen) |
|
||||
| **Icon-Only** | Inline-Aktionen (Edit, X) | Quadratisch 32×32, Ghost-Stil mit Lucide-Icon zentral. `aria-label` Pflicht. |
|
||||
|
||||
**Größenstaffel:** `sm` (28 px Höhe), `md` (36 px Höhe, Default), `lg` (44 px Höhe, für Haupt-CTAs auf dem Puls).
|
||||
|
||||
### Feedback Patterns
|
||||
|
||||
- **Sofortige optimistic Reaktion** bei Mutations (Checkbox abhaken, Ampel setzen). Bei Server-Fehler: Rollback + roter Toast „Speichern fehlgeschlagen — bitte nochmal versuchen.".
|
||||
- **Toast bei jeder Mutation** mit Voice-Anker. Toast erscheint bottom-right, 2 s sichtbar, ohne Dismiss-Button (außer Fehler-Toasts haben ein X).
|
||||
- **Skeleton beim Initial-Load.** Niemals leerer Bildschirm, niemals Spinner als Alleinstellungsmerkmal.
|
||||
- **Empty-States als Tropfen-Information:** Wenn ein Feed leer ist, nicht „Keine Daten" — sondern „Hier erscheinen die Updates des Tages, sobald jemand reflektiert hat.". Voice-konform.
|
||||
- **Error-States immer mit Aktion.** Statt nur „Fehler beim Laden": „Konnte Projekt nicht laden. [Erneut versuchen]".
|
||||
|
||||
### Form Patterns
|
||||
|
||||
- **Labels über dem Feld**, nicht inline-floating.
|
||||
- **Pflichtfelder mit `*`** im Label, deutlich aber dezent.
|
||||
- **Validation onBlur**, nicht onChange — verhindert nervige Rotmeldungen während Tippen.
|
||||
- **Submit-Button disabled** während Pending-Request mit Mini-Spinner statt komplettem Loading-Overlay.
|
||||
- **Form-Errors unter dem Feld**, schwarzer Text, kein Rot (außer Border des Feldes wird rot).
|
||||
- **Eine Spalte Form-Layout**, kein Side-by-Side außer Datum/Uhrzeit-Paare.
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
- **TopNav fest:** `cockpit ^` Logo links · Tabs (Puls / Entwicklung / Vertrieb / Marketing / Mein heute / Archiv) · rechts: Streak-Badge + Avatar.
|
||||
- **Active-Indicator:** Aktiver Tab bekommt einen 2 px dicken schwarzen Underline. Kein farbiges Highlight.
|
||||
- **Breadcrumbs in Detail-Views:** „Puls › Entwicklung › Projekt X" (Klickbare Pfad-Segmente).
|
||||
- **Browser-Back funktioniert immer.** React-Router-History-Pflege.
|
||||
- **Keyboard-Shortcuts:** `g p` Puls, `g h` Mein heute, `g e` Entwicklung, `g v` Vertrieb, `g m` Marketing, `g a` Archiv. `?` öffnet Cheatsheet. `n` öffnet „Neuer Blocker"-Dialog. Esc schließt.
|
||||
|
||||
### Additional Patterns
|
||||
|
||||
- **Status-Pill-Konvention:** Immer als `[● Label]` formatieren — kleiner farbiger Punkt + Label, in Pill mit `radius-full`.
|
||||
- **Datum-Anzeige:** „Mo, 25. Mai" für Werktage; „vor 2 Tagen" für relative Zeitangaben; ISO-Format `2026-05-25` nur in technischen Audit-Anzeigen.
|
||||
- **EUR-Anzeige:** Tausenderpunkt + Komma + Euro-Zeichen mit Leerzeichen, z. B. `12.500,00 €`. Niemals `12500€` oder `EUR 12500`.
|
||||
- **Avatar-Konvention:** Initialen (max 2 Zeichen) in schwarzem Kreis mit weißer Schrift; Bilder optional aus Authentik-Profilbild Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Responsive Design & Accessibility
|
||||
|
||||
### Responsive Strategy
|
||||
|
||||
- **Desktop-first** (`min-width` queries).
|
||||
- **Drei Sicht-Ebenen:**
|
||||
- **Desktop (≥ 1280 px):** Volles 3-Spalten-Layout im Puls, alle Tabs in TopNav sichtbar.
|
||||
- **Tablet (768–1279 px):** Puls auf 2-Spalten-Grid; TopNav klappt überlange Tabs in „Mehr"-Dropdown.
|
||||
- **Mobile (< 768 px):** Single-Column-Stack; TopNav wird zu Bottom-Tab-Bar mit 4 Items (Puls / Mein heute / Bereiche-Dropdown / Profil). Reflexion und Einstempeln sind **lesbar**, das Schreiben wird ausdrücklich auf Desktop empfohlen (Info-Hinweis im Mobile-Layout).
|
||||
|
||||
### Breakpoint Strategy
|
||||
|
||||
| Breakpoint | Größe | Verwendung |
|
||||
|---|---|---|
|
||||
| `sm` | ≥ 640 px | Vereinzelte Layout-Anpassungen |
|
||||
| `md` | ≥ 768 px | Tablet-Layout aktiviert |
|
||||
| `lg` | ≥ 1024 px | Mehr Padding, größere Cards |
|
||||
| `xl` | ≥ 1280 px | Desktop-Volllayout, 3-Spalten |
|
||||
| `2xl` | ≥ 1536 px | Container bleibt bei 1280 px gecappt, mehr Außen-Padding |
|
||||
|
||||
### Accessibility Strategy
|
||||
|
||||
- **Standard:** WCAG 2.1 AA durchgängig.
|
||||
- **Keyboard-First-Pflicht.** Alle interaktiven Elemente erreichbar via Tab, Aktivierung mit Enter/Space.
|
||||
- **Focus-Ring sichtbar:** `outline: 2px solid var(--color-ink)` + 2 px Offset auf jedem fokussierten Element.
|
||||
- **Skip-Link** ganz oben („Zum Hauptinhalt springen") für Screenreader.
|
||||
- **Heading-Hierarchie sauber:** `<h1>` einmal pro Seite (Seitentitel), `<h2>` für Sektionen, `<h3>` für Sub-Sektionen.
|
||||
- **Live-Regionen für Toast-Notifications:** `aria-live="polite"` für Erfolg, `aria-live="assertive"` für Fehler.
|
||||
- **Alt-Texte** auf jedem nicht-dekorativen Bild und SVG. `<KeyCaret aria-hidden="true">` für reine Decorations.
|
||||
- **Reduced-Motion:** Beim System-Setting `prefers-reduced-motion: reduce` werden Fade-Ins auf `duration: 0` gesetzt.
|
||||
- **Dark-Mode-Support:** Phase 2.
|
||||
- **Hochkontrast-Modus:** automatisch durch Brand-Kontrast (19:1 default) — kein Custom-High-Contrast-Theme nötig.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **axe-core integration** in Vitest-Tests pro Komponente.
|
||||
- **Playwright-E2E-Tests** mit `@axe-core/playwright` auf die kritischen Routen (UJ-1, UJ-2, UJ-5).
|
||||
- **Manuelle Keyboard-Audits** vor jedem Release: Tab-durchnavigieren der Puls-Seite + Mein-Heute + Reflexion.
|
||||
- **Manuelle Screenreader-Audit** (VoiceOver oder NVDA) vor MVP-Launch auf die kritischen User Journeys.
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
- **Tailwind-Plugins** für besseren Focus-Ring (`@tailwindcss/forms`) und Typografie (`@tailwindcss/typography`).
|
||||
- **Eslint-Plugin-jsx-a11y** aktiviert mit `recommended` ruleset, blockt PRs bei A11y-Verstößen.
|
||||
- **Lighthouse-Scoring-Ziel:** ≥ 95 in Accessibility-Score (gemessen auf jeder Route im CI).
|
||||
@@ -0,0 +1,44 @@
|
||||
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
|
||||
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||
BMad Method,bmad-investigate,Investigate,IN,Forensic case investigation calibrated to the input. Evidence-graded analysis with hypothesis tracking. Produces a structured case file.,,4-implementation,,,false,implementation_artifacts,investigation report,
|
||||
BMad Method,bmad-brainstorming,Brainstorm Project,BP,Expert guided facilitation through a single or multiple techniques.,,,1-analysis,,,false,planning_artifacts,brainstorming session
|
||||
BMad Method,bmad-market-research,Market Research,MR,Market analysis competitive landscape customer needs and trends.,,,1-analysis,,,false,planning_artifacts|project-knowledge,research documents
|
||||
BMad Method,bmad-domain-research,Domain Research,DR,Industry domain deep dive subject matter expertise and terminology.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||
BMad Method,bmad-technical-research,Technical Research,TR,Technical feasibility architecture options and implementation approaches.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||
BMad Method,bmad-product-brief,Create Brief,CB,An expert guided experience to nail down your product idea in a brief. a gentler approach than PRFAQ when you are already sure of your concept and nothing will sway you.,,-A,1-analysis,,,false,planning_artifacts,product brief
|
||||
BMad Method,bmad-prfaq,PRFAQ Challenge,WB,Working Backwards guided experience to forge and stress-test your product concept to ensure you have a great product that users will love and need through the PRFAQ gauntlet to determine feasibility and alignment with user needs. alternative to product brief.,,-H,1-analysis,,,false,planning_artifacts,prfaq document
|
||||
BMad Method,bmad-prd,Create Edit and Review PRD,PRD,"Facilitated PRD workflow — create a new PRD via coached discovery, update an existing one against a change signal, or validate a finished PRD against a checklist with an HTML findings report.",,,2-planning,bmad-product-brief,,true,planning_artifacts,prd
|
||||
BMad Method,bmad-create-ux-design,Create UX,CU,"Guidance through realizing the plan for your UX, strongly recommended if a UI is a primary piece of the proposed project.",,,2-planning,bmad-prd,,false,planning_artifacts,ux design
|
||||
BMad Method,bmad-create-architecture,Create Architecture,CA,Guided workflow to document technical decisions.,,,3-solutioning,,,true,planning_artifacts,architecture
|
||||
BMad Method,bmad-create-epics-and-stories,Create Epics and Stories,CE,,,,3-solutioning,bmad-create-architecture,,true,planning_artifacts,epics and stories
|
||||
BMad Method,bmad-check-implementation-readiness,Check Implementation Readiness,IR,Ensure PRD UX Architecture and Epics Stories are aligned.,,,3-solutioning,bmad-create-epics-and-stories,,true,planning_artifacts,readiness report
|
||||
BMad Method,bmad-sprint-planning,Sprint Planning,SP,Kicks off implementation by producing a plan the implementation agents will follow in sequence for every story.,,,4-implementation,,,true,implementation_artifacts,sprint status
|
||||
BMad Method,bmad-sprint-status,Sprint Status,SS,Anytime: Summarize sprint status and route to next workflow.,,,4-implementation,bmad-sprint-planning,,false,,
|
||||
BMad Method,bmad-create-story,Create Story,CS,Story cycle start: Prepare first found story in the sprint plan that is next or a specific epic/story designation.,create,,4-implementation,bmad-sprint-planning,bmad-create-story:validate,true,implementation_artifacts,story
|
||||
BMad Method,bmad-create-story,Validate Story,VS,Validates story readiness and completeness before development work begins.,validate,,4-implementation,bmad-create-story:create,bmad-dev-story,false,implementation_artifacts,story validation report
|
||||
BMad Method,bmad-dev-story,Dev Story,DS,Story cycle: Execute story implementation tasks and tests then CR then back to DS if fixes needed.,,,4-implementation,bmad-create-story:validate,,true,,
|
||||
BMad Method,bmad-code-review,Code Review,CR,Story cycle: If issues back to DS if approved then next CS or ER if epic complete.,,,4-implementation,bmad-dev-story,,false,,
|
||||
BMad Method,bmad-checkpoint-preview,Checkpoint,CK,Guided walkthrough of a change from purpose and context into details. Use for human review of commits branches or PRs.,,,4-implementation,,,false,,
|
||||
BMad Method,bmad-qa-generate-e2e-tests,QA Automation Test,QA,Generate automated API and E2E tests for implemented code. NOT for code review or story validation — use CR for that.,,,4-implementation,bmad-dev-story,,false,implementation_artifacts,test suite
|
||||
BMad Method,bmad-retrospective,Retrospective,ER,Optional at epic end: Review completed work lessons learned and next epic or if major issues consider CC.,,,4-implementation,bmad-code-review,,false,implementation_artifacts,retrospective
|
||||
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,,anytime,,,false,project-knowledge,*
|
||||
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,,anytime,,,false,output_folder,project context
|
||||
BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,,anytime,,,false,implementation_artifacts,spec and project implementation
|
||||
BMad Method,bmad-correct-course,Correct Course,CC,Navigate significant changes. May recommend start over update PRD redo architecture sprint planning or correct epics and stories.,,,anytime,,,false,planning_artifacts,change proposal
|
||||
BMad Method,bmad-agent-tech-writer,Write Document,WD,"Describe in detail what you want, and the agent will follow documentation best practices. Multi-turn conversation with subprocess for research/review.",write,,anytime,,,false,project-knowledge,document
|
||||
BMad Method,bmad-agent-tech-writer,Update Standards,US,Update agent memory documentation-standards.md with your specific preferences if you discover missing document conventions.,update-standards,,anytime,,,false,_bmad/_memory/tech-writer-sidecar,standards
|
||||
BMad Method,bmad-agent-tech-writer,Mermaid Generate,MG,Create a Mermaid diagram based on user description. Will suggest diagram types if not specified.,mermaid,,anytime,,,false,planning_artifacts,mermaid diagram
|
||||
BMad Method,bmad-agent-tech-writer,Validate Document,VD,Review the specified document against documentation standards and best practices. Returns specific actionable improvement suggestions organized by priority.,validate,[path],anytime,,,false,planning_artifacts,validation report
|
||||
BMad Method,bmad-agent-tech-writer,Explain Concept,EC,Create clear technical explanations with examples and diagrams for complex concepts.,explain,[topic],anytime,,,false,project_knowledge,explanation
|
||||
Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
|
||||
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,,anytime,,,false,,
|
||||
Core,bmad-help,BMad Help,BH,,,,anytime,,,false,,
|
||||
Core,bmad-index-docs,Index Docs,ID,Use when LLM needs to understand available docs without loading everything.,,,anytime,,,false,,
|
||||
Core,bmad-shard-doc,Shard Document,SD,Use when doc becomes too large (>500 lines) to manage effectively.,,[path],anytime,,,false,,
|
||||
Core,bmad-editorial-review-prose,Editorial Review - Prose,EP,Use after drafting to polish written content.,,[path],anytime,,,false,report located with target document,three-column markdown table with suggested fixes
|
||||
Core,bmad-editorial-review-structure,Editorial Review - Structure,ES,Use when doc produced from multiple subprocesses or needs structural improvement.,,[path],anytime,,,false,report located with target document,
|
||||
Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",,[path],anytime,,,false,,
|
||||
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,,
|
||||
Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s)
|
||||
Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files
|
||||
|
@@ -0,0 +1,232 @@
|
||||
type,name,module,path,hash
|
||||
"yaml","manifest","_config","_config/manifest.yaml","b13e521bbb5ebdd1a441988d30c424d7d782db134ab9ea1e00333066b496ee78"
|
||||
"csv","documentation-requirements","bmm","bmm/1-analysis/bmad-document-project/documentation-requirements.csv","d1253b99e88250f2130516b56027ed706e643bfec3d99316727a4c6ec65c6c1d"
|
||||
"csv","domain-complexity","bmm","bmm/3-solutioning/bmad-create-architecture/data/domain-complexity.csv","3dc34ed39f1fc79a51f7b8fc92087edb7cd85c4393a891d220f2e8dd5a101c70"
|
||||
"csv","module-help","bmm","bmm/module-help.csv","b8c199e3bb160060887211772af2d21b785ce7a3d646699e39520f867af5400f"
|
||||
"csv","project-types","bmm","bmm/3-solutioning/bmad-create-architecture/data/project-types.csv","12343635a2f11343edb1d46906981d6f5e12b9cad2f612e13b09460b5e5106e7"
|
||||
"html","validation-report-template","bmm","bmm/2-plan-workflows/bmad-prd/assets/validation-report-template.html","a80becdd8205d2d7c18738855830b096172a496b96b5ca8a0820ec55c9bb8bf9"
|
||||
"json","bmad-manifest","bmm","bmm/1-analysis/bmad-prfaq/bmad-manifest.json","7908cab6f0cd66f1f3427b4cc659975b4737bdc44f789b23c6a895fcd084c4bc"
|
||||
"json","project-scan-report-schema","bmm","bmm/1-analysis/bmad-document-project/templates/project-scan-report-schema.json","8466965321f1db22f5013869636199f67e0113706283c285a7ffbbf5efeea321"
|
||||
"md","architecture-decision-template","bmm","bmm/3-solutioning/bmad-create-architecture/architecture-decision-template.md","5d9adf90c28df61031079280fd2e49998ec3b44fb3757c6a202cda353e172e9f"
|
||||
"md","artifact-analyzer","bmm","bmm/1-analysis/bmad-prfaq/agents/artifact-analyzer.md","7bdc44830f8d593346ec0ee15e36e1e431432fcc6c38b70bb861999315c9cfa4"
|
||||
"md","brief-template","bmm","bmm/1-analysis/bmad-product-brief/assets/brief-template.md","bb28edf75d023067551c8d417b19b8803324e7acfb2ad0c80f882e9134f4c1f1"
|
||||
"md","case-file-template","bmm","bmm/4-implementation/bmad-investigate/references/case-file-template.md","9dccb41f3f3fc796b42411966e51b32c4231aaceb4b6bb1880a16225a990002e"
|
||||
"md","checklist","bmm","bmm/1-analysis/bmad-document-project/checklist.md","581b0b034c25de17ac3678db2dbafedaeb113de37ddf15a4df6584cf2324a7d7"
|
||||
"md","checklist","bmm","bmm/4-implementation/bmad-correct-course/checklist.md","3e082b95def90ccb876e3101ce0bbaf797a0f03a9471e1347361897f27977327"
|
||||
"md","checklist","bmm","bmm/4-implementation/bmad-create-story/checklist.md","b94e28e774c3be0288f04ea163424bece4ddead5cd3f3680d1603ed07383323a"
|
||||
"md","checklist","bmm","bmm/4-implementation/bmad-dev-story/checklist.md","630b68c6824a8785003a65553c1f335222b17be93b1bd80524c23b38bde1d8af"
|
||||
"md","checklist","bmm","bmm/4-implementation/bmad-qa-generate-e2e-tests/checklist.md","b58f810aeb1040c2f6758c88aa133afce72f8cc178d3d97ff0fbaa3d943057dc"
|
||||
"md","checklist","bmm","bmm/4-implementation/bmad-sprint-planning/checklist.md","80b10aedcf88ab1641b8e5f99c9a400c8fd9014f13ca65befc5c83992e367dd7"
|
||||
"md","compile-epic-context","bmm","bmm/4-implementation/bmad-quick-dev/compile-epic-context.md","5cfda02f252941e415b80c57b4528f46226b3cbf456ad45d78fcb5a7ef4816e2"
|
||||
"md","customer-faq","bmm","bmm/1-analysis/bmad-prfaq/references/customer-faq.md","96f8565197649c58908a1d61b6cd805fd01f57da7945ba889c18d087ad597aeb"
|
||||
"md","deep-dive-instructions","bmm","bmm/1-analysis/bmad-document-project/workflows/deep-dive-instructions.md","a79e24b93a25ab456062916a9dad7b6a16dc43ac0b4b555700d3c3751cff0d25"
|
||||
"md","deep-dive-template","bmm","bmm/1-analysis/bmad-document-project/templates/deep-dive-template.md","6198aa731d87d6a318b5b8d180fc29b9aa53ff0966e02391c17333818e94ffe9"
|
||||
"md","deep-dive-workflow","bmm","bmm/1-analysis/bmad-document-project/workflows/deep-dive-workflow.md","a64d98dfa3b771df2853c4fa19a4e9c90d131e409e13b4c6f5e494d6ac715125"
|
||||
"md","discover-inputs","bmm","bmm/4-implementation/bmad-create-story/discover-inputs.md","dfedba6a8ea05c9a91c6d202c4b29ee3ea793d8ef77575034787ae0fef280507"
|
||||
"md","epics-template","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/templates/epics-template.md","a804f740155156d89661fa04e7a4264a8f712c4dc227c44fd8ae804a9b0f6b72"
|
||||
"md","explain-concept","bmm","bmm/1-analysis/bmad-agent-tech-writer/explain-concept.md","6ea82dbe4e41d4bb8880cbaa62d936e40cef18f8c038be73ae6e09c462abafc9"
|
||||
"md","full-scan-instructions","bmm","bmm/1-analysis/bmad-document-project/workflows/full-scan-instructions.md","2235945df2ae261265187447ce593238e132a026a9b88d3507a1f1d6808a0263"
|
||||
"md","full-scan-workflow","bmm","bmm/1-analysis/bmad-document-project/workflows/full-scan-workflow.md","3bff88a392c16602bd44730f32483505e73e65e46e82768809c13a0a5f55608b"
|
||||
"md","generate-trail","bmm","bmm/4-implementation/bmad-checkpoint-preview/generate-trail.md","4a5936d86fbe5a85285b4535097b1e2edda8849da35586f4b588a982d7224459"
|
||||
"md","headless","bmm","bmm/2-plan-workflows/bmad-prd/references/headless.md","bb7ef8900d6af6a60eb6a72c596bb3c113dde2dfbee703b99bc4ea25eabba40e"
|
||||
"md","headless-schemas","bmm","bmm/2-plan-workflows/bmad-prd/assets/headless-schemas.md","66a5b1f0ce22c7baf3a6b6a14feb7458491b1106bef437389cd6291f81eebd7f"
|
||||
"md","index-template","bmm","bmm/1-analysis/bmad-document-project/templates/index-template.md","42c8a14f53088e4fda82f26a3fe41dc8a89d4bcb7a9659dd696136378b64ee90"
|
||||
"md","instructions","bmm","bmm/1-analysis/bmad-document-project/instructions.md","9f4bc3a46559ffd44289b0d61a0f8f26f829783aa1c0e2a09dfa807fa93eb12f"
|
||||
"md","internal-faq","bmm","bmm/1-analysis/bmad-prfaq/references/internal-faq.md","26eb83f844cda1ed8efb50f4703d61713ada8a64bd27eb387f759f858b5748de"
|
||||
"md","mermaid-gen","bmm","bmm/1-analysis/bmad-agent-tech-writer/mermaid-gen.md","1d83fcc5fa842bc31ecd9fd7e45fbf013fabcadf0022d3391fff5b53b48e4b5d"
|
||||
"md","prd-template","bmm","bmm/2-plan-workflows/bmad-prd/assets/prd-template.md","ebba04ba47740d9127e9bcb30f9a829c7018014208e015e8b727180d12694b14"
|
||||
"md","prd-validation-checklist","bmm","bmm/2-plan-workflows/bmad-prd/assets/prd-validation-checklist.md","c5def5aa51aba5b7497f3552e06e86d86d15b9d94be26d056c0bbd1b9375c50a"
|
||||
"md","press-release","bmm","bmm/1-analysis/bmad-prfaq/references/press-release.md","c406adb0e2d2cc326cbc45d0174f89d014523448ad82bc272293999d22aec596"
|
||||
"md","prfaq-template","bmm","bmm/1-analysis/bmad-prfaq/assets/prfaq-template.md","b27e6964f0437ab4e78c8c0ffbe5052c28e3b3ef2fc811726cbb394d5a5c7559"
|
||||
"md","project-context-template","bmm","bmm/3-solutioning/bmad-generate-project-context/project-context-template.md","54e351394ceceb0ac4b5b8135bb6295cf2c37f739c7fd11bb895ca16d79824a5"
|
||||
"md","project-overview-template","bmm","bmm/1-analysis/bmad-document-project/templates/project-overview-template.md","a7c7325b75a5a678dca391b9b69b1e3409cfbe6da95e70443ed3ace164e287b2"
|
||||
"md","readiness-report-template","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/templates/readiness-report-template.md","0da97ab1e38818e642f36dc0ef24d2dae69fc6e0be59924dc2dbf44329738ff6"
|
||||
"md","research.template","bmm","bmm/1-analysis/research/bmad-domain-research/research.template.md","507bb6729476246b1ca2fca4693986d286a33af5529b6cd5cb1b0bb5ea9926ce"
|
||||
"md","research.template","bmm","bmm/1-analysis/research/bmad-market-research/research.template.md","507bb6729476246b1ca2fca4693986d286a33af5529b6cd5cb1b0bb5ea9926ce"
|
||||
"md","research.template","bmm","bmm/1-analysis/research/bmad-technical-research/research.template.md","507bb6729476246b1ca2fca4693986d286a33af5529b6cd5cb1b0bb5ea9926ce"
|
||||
"md","SKILL","bmm","bmm/1-analysis/bmad-agent-analyst/SKILL.md","dff8fbd39de875ccc6735204561f2b7b877788843ecd6e8b6001e7ca7f39641e"
|
||||
"md","SKILL","bmm","bmm/1-analysis/bmad-agent-tech-writer/SKILL.md","d39ffa2a931361e9fea11ef5eb96da0fc6b8dbab70dc200142404a50b78e7665"
|
||||
"md","SKILL","bmm","bmm/1-analysis/bmad-document-project/SKILL.md","efe25c8c27116faeeef119078953c38e827db787338fdb2dca65c086f42e0c4b"
|
||||
"md","SKILL","bmm","bmm/1-analysis/bmad-prfaq/SKILL.md","9e864f574cc2e1411284edb625a17268aab1f894c46baac2edc3bcf1383adaca"
|
||||
"md","SKILL","bmm","bmm/1-analysis/bmad-product-brief/SKILL.md","6078a95cf9500c4c9010e834adc290fa2c793cd4e0e6f2d47e36dc477be24112"
|
||||
"md","SKILL","bmm","bmm/1-analysis/research/bmad-domain-research/SKILL.md","53d2ee5ccbd1d73290e752cff0235bfdb4928dc5ee70f347992ef15621341124"
|
||||
"md","SKILL","bmm","bmm/1-analysis/research/bmad-market-research/SKILL.md","720183e577a5f6f307f320703a5ec522190723e957cfe6ad7821d8d833dea0c7"
|
||||
"md","SKILL","bmm","bmm/1-analysis/research/bmad-technical-research/SKILL.md","1c4b61afaeedd3191db65923253be56084c64e4f832f0e2b9179671f91b5bf20"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-agent-pm/SKILL.md","3a8550daac2df2f01a7c04b66148c1e30b50f81730f043415f2a1aba6314f8a4"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-agent-ux-designer/SKILL.md","3ac99856f0ee9bae3ba05cefab32e90920b4a1fdf0e8c4bf9c440be31f9e5a1f"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-create-prd/SKILL.md","143c5c2d85734021db343ff3dbf143e804eddbf9fb519fb824b70f1a9154767d"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-create-ux-design/SKILL.md","24a4123035194bd3517a059417dcd8114db90f99314ed1a51cfdbbaff02860b2"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-edit-prd/SKILL.md","31ecfe3c16513d994e44e648a7f2d4f4a6ccf297ca6c1826aa68ef7b053e7ff2"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-prd/SKILL.md","aac7b30d01f9d9275fb8339e65c58e4b21c4ae22ddc5810a05a464da5951dec0"
|
||||
"md","SKILL","bmm","bmm/2-plan-workflows/bmad-validate-prd/SKILL.md","931439b8c84bc454d0596a1ad35d6583c1926c941f2928c54b31aea702c24b53"
|
||||
"md","SKILL","bmm","bmm/3-solutioning/bmad-agent-architect/SKILL.md","1bb79ab9d9ae3180a6142d9d20c2a5cb15ad5d5f28e38a7f847295dd04d2299f"
|
||||
"md","SKILL","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/SKILL.md","c683db5628781e6dba6fad388ed65cf7561a208e773f72c786918743e379abef"
|
||||
"md","SKILL","bmm","bmm/3-solutioning/bmad-create-architecture/SKILL.md","b12711f1655cb809bb74a3e11e40943dce225ccba9d01974d5bf521a5867ba23"
|
||||
"md","SKILL","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/SKILL.md","9db05e4a63ead6e416009fdec250bb9b749b361331964b601559ffeedd2f1c0f"
|
||||
"md","SKILL","bmm","bmm/3-solutioning/bmad-generate-project-context/SKILL.md","3d8c20e13e62981464365509fc5e1dfd31e70a94e8c08b048a3e3866ea00ff43"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-agent-dev/SKILL.md","ab47a75e3031bc58a9783e4f8dece8d44dfdacb7ba74cd56f766b260f9993218"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-checkpoint-preview/SKILL.md","fcdd92af6b97ae64f99a89c37deef0074a974ffa577263e6c79c4b6734c63bda"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-code-review/SKILL.md","6801f6742156f125185b1eb6e10c58dfde4ff6545b2e6278acf68cbcfbe0abe4"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-correct-course/SKILL.md","25be5dd528b5c3996e5fa02fc30b72377f688436742c6962f4a1e9a2dac46e55"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-create-story/SKILL.md","81517ba8ef137a15002d6d21ef18a1e88190c74ac9e0c5b29227e4059870809a"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-dev-story/SKILL.md","5e7d3bca5051ff8f009bcf608e3fdac3260c2aa6faa8df170a954fe03a4a03f8"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-investigate/SKILL.md","79905849c7ce4ef4c1c647365c52837a8ede2af01a25ce6267dfe8ba64683652"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md","ad6025f0279ef9fac2f6c76d8f612c37ccc23f8062b7ac0ac40708b49cc7db80"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-quick-dev/SKILL.md","2b2e57e5327fc69962e6529a0c17498185b571646a9c7360e5c15be14ea7e63b"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-retrospective/SKILL.md","997308d999545fa6c82b8652a2973e1d89d82cfbf892531e25e491291a95c33e"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-sprint-planning/SKILL.md","21946cdaef8115deee6ce322c460e8af39368509d700c778cd7109f50a6821eb"
|
||||
"md","SKILL","bmm","bmm/4-implementation/bmad-sprint-status/SKILL.md","2fe141bc2033ea341a73fb93349fcee0296d8d8714fcf984ab056bc0abae0a19"
|
||||
"md","source-tree-template","bmm","bmm/1-analysis/bmad-document-project/templates/source-tree-template.md","109bc335ebb22f932b37c24cdc777a351264191825444a4d147c9b82a1e2ad7a"
|
||||
"md","spec-template","bmm","bmm/4-implementation/bmad-quick-dev/spec-template.md","3ee15d5a63cf5eeee74149c590668fc61d0e44023eac12988a1ca2a9438a9d39"
|
||||
"md","step-01-clarify-and-route","bmm","bmm/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md","b5eb9f0cecf2a462885b8e7b4c21abbaec1e95c8abbd76bc410d9090fd8379f0"
|
||||
"md","step-01-discover","bmm","bmm/3-solutioning/bmad-generate-project-context/steps/step-01-discover.md","8b2c8c7375f8a3c28411250675a28c0d0a9174e6c4e67b3d53619888439c4613"
|
||||
"md","step-01-document-discovery","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md","c763b512d55906122433cb65c1bcd5b5b283e45eacdc07281c8ec7596b6b3980"
|
||||
"md","step-01-gather-context","bmm","bmm/4-implementation/bmad-code-review/steps/step-01-gather-context.md","d0ee7558605e9d48b5b6f15d9b535542eb6d922613f529bb520326eacade4171"
|
||||
"md","step-01-init","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-01-init.md","efee243f13ef54401ded88f501967b8bc767460cec5561b2107fc03fe7b7eab1"
|
||||
"md","step-01-init","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-01-init.md","64d5501aea0c0005db23a0a4d9ee84cf4e9239f553c994ecc6b1356917967ccc"
|
||||
"md","step-01-init","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-01-init.md","c9a1627ecd26227e944375eb691e7ee6bc9f5db29a428a5d53e5d6aef8bb9697"
|
||||
"md","step-01-init","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-01-init.md","0b257533a0ce34d792f621da35325ec11cb883653e3ad546221ee1f0dee5edcd"
|
||||
"md","step-01-init","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-01-init.md","5119205b712ebda0cd241c3daad217bb0f6fa9e6cb41d6635aec6b7fe83b838a"
|
||||
"md","step-01-orientation","bmm","bmm/4-implementation/bmad-checkpoint-preview/step-01-orientation.md","d9e3b949c36d49a025f3535773af2b51888fe4ce616b6d6d69683a122716b1d2"
|
||||
"md","step-01-validate-prerequisites","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/steps/step-01-validate-prerequisites.md","5c2aabc871363d84fc2e12fd83a3889e9d752b6bd330e31a0067c96204dd4880"
|
||||
"md","step-01b-continue","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-01b-continue.md","4d42c6b83eaa720975bf2206a7eea1a8c73ae922668cc2ef03d34c49ab066c19"
|
||||
"md","step-01b-continue","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-01b-continue.md","4bf216008297dcea25f8be693109cf17879c621865b302c994cdd15aa5124e5f"
|
||||
"md","step-02-context","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-02-context.md","4381c5128de7d5c02ac806a1263e3965754bd2598954f3188219fbd87567e5c9"
|
||||
"md","step-02-customer-behavior","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-02-customer-behavior.md","bac4de244049f90d1f2eb95e2cc9389cc84966d9538077fef1ec9c35e4533849"
|
||||
"md","step-02-design-epics","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/steps/step-02-design-epics.md","7c66987808c1f84e853fe54b7aff26d209196d450b5644704110f124a15179bc"
|
||||
"md","step-02-discovery","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-02-discovery.md","9ffd5b31cc869b564e4d78cdc70767f0fb1b04db4c40201ccfa9dde75739fa8d"
|
||||
"md","step-02-domain-analysis","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-02-domain-analysis.md","385a288d9bbb0adf050bcce4da4dad198a9151822f9766900404636f2b0c7f9d"
|
||||
"md","step-02-generate","bmm","bmm/3-solutioning/bmad-generate-project-context/steps/step-02-generate.md","b1f063edae66a74026b67a79a245cec7ee85438bafcacfc70dcf6006b495e060"
|
||||
"md","step-02-plan","bmm","bmm/4-implementation/bmad-quick-dev/step-02-plan.md","72f4df415adceaaf554166983559e058c6a019d783d0f87cf42c401db3c5f52c"
|
||||
"md","step-02-prd-analysis","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md","38be2bf4b924c0b5b395b57d68f685d790ade7b1a6c10993d3c550675f87d954"
|
||||
"md","step-02-review","bmm","bmm/4-implementation/bmad-code-review/steps/step-02-review.md","1883758e0e91fba439497d04417f03d778f3f6fb12754f49aa8cfa5e15489f70"
|
||||
"md","step-02-technical-overview","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-02-technical-overview.md","9c7582241038b16280cddce86f2943216541275daf0a935dcab78f362904b305"
|
||||
"md","step-02-walkthrough","bmm","bmm/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md","66cf893f8f968ee81034e9ccd8c20415692c3a8c23a9a143c2245fe6c800acdc"
|
||||
"md","step-03-competitive-landscape","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-03-competitive-landscape.md","f10aa088ba00c59491507f6519fb314139f8be6807958bb5fd1b66bff2267749"
|
||||
"md","step-03-complete","bmm","bmm/3-solutioning/bmad-generate-project-context/steps/step-03-complete.md","e61463db76a8fa060411aa24127aee936d646b97564e9e2a883494ea50e68464"
|
||||
"md","step-03-core-experience","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-03-core-experience.md","1f58c8a2f6872f468629ecb67e94f793af9d10d2804fe3e138eba03c090e00c5"
|
||||
"md","step-03-create-stories","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/steps/step-03-create-stories.md","c5b787a82e4e49ed9cd9c028321ee1689f32b8cd69d89eea609b37cd3d481afc"
|
||||
"md","step-03-customer-pain-points","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-03-customer-pain-points.md","5b2418ccaaa89291c593efed0311b3895faad1e9181800d382da823a8eb1312a"
|
||||
"md","step-03-detail-pass","bmm","bmm/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md","d48163b9f305f15af57729a8443142e47beb6c3e977554afe12b39ee49cb9fc0"
|
||||
"md","step-03-epic-coverage-validation","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md","7b187f03a47cba0325fcfd10240410db9c59d93768342fc2dd3de2a01ec23356"
|
||||
"md","step-03-implement","bmm","bmm/4-implementation/bmad-quick-dev/step-03-implement.md","4d848865eafe5eeba7d83529c766bd410a9cc20a05de1d6a764954c7275b4749"
|
||||
"md","step-03-integration-patterns","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-03-integration-patterns.md","005d517a2f962e2172e26b23d10d5e6684c7736c0d3982e27b2e72d905814ad9"
|
||||
"md","step-03-starter","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-03-starter.md","b7727e0f37bc5325e15abad1c54bef716d617df423336090189efd1d307a0b3f"
|
||||
"md","step-03-triage","bmm","bmm/4-implementation/bmad-code-review/steps/step-03-triage.md","91eaa27f6a167702ead00da9e93565c9bff79dce92c02eccbca61b1d1ed39a80"
|
||||
"md","step-04-architectural-patterns","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-04-architectural-patterns.md","4636f23e9c585a7a0c90437a660609d913f16362c3557fc2e71d408d6b9f46ce"
|
||||
"md","step-04-customer-decisions","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-04-customer-decisions.md","f0bc25f2179b7490e7a6704159a32fc9e83ab616022355ed53acfe8e2f7059d5"
|
||||
"md","step-04-decisions","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-04-decisions.md","7fc0ebb63ab5ad0efc470f1063c15f14f52f5d855da2382fd17576cf060a8763"
|
||||
"md","step-04-emotional-response","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-04-emotional-response.md","75724811b170c8897e230a49e968e1db357fef3387008b0906b5ff79a43dbff9"
|
||||
"md","step-04-final-validation","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/steps/step-04-final-validation.md","6b83fcf177c24c6b43c2eb0df2efc1430cb0f357b3ab20ee8a5c5483c3fee079"
|
||||
"md","step-04-present","bmm","bmm/4-implementation/bmad-code-review/steps/step-04-present.md","eee5687e9d7da9a0aeffd78180b8289fca804fc2d59896db5f65a8dd476e4869"
|
||||
"md","step-04-regulatory-focus","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-04-regulatory-focus.md","d22035529efe91993e698b4ebf297bf2e7593eb41d185a661c357a8afc08977b"
|
||||
"md","step-04-review","bmm","bmm/4-implementation/bmad-quick-dev/step-04-review.md","e62a50a37ab20eb426f5abd44179dfac51548707b5e85e6fe7817c144cbe3dae"
|
||||
"md","step-04-testing","bmm","bmm/4-implementation/bmad-checkpoint-preview/step-04-testing.md","28a56e868968ea2d18add0df8c4bccced0f94b698e218df3d45ddac072ce369c"
|
||||
"md","step-04-ux-alignment","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-04-ux-alignment.md","f71e5f0d77615e885ae40fdee6b04c1dd6e472c871f87b515fe869cb5f6966fb"
|
||||
"md","step-05-competitive-analysis","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-05-competitive-analysis.md","17532051ad232cfc859f09ac3b44f9f4d542eb24cff8d07317126ccdff0d225a"
|
||||
"md","step-05-epic-quality-review","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-05-epic-quality-review.md","d8a84e57f4e3a321734b5b5d093458ceb1e338744f18954c5a204f5ce3576185"
|
||||
"md","step-05-implementation-research","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-05-implementation-research.md","e2b8a2c79bcebadc85f3823145980fa47d7e7be8d1c112f686c6223c8c138608"
|
||||
"md","step-05-inspiration","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-05-inspiration.md","b0cadcd4665c46d2e6e89bdb45ddfdd4e4aac47b901e59aa156b935878a2b124"
|
||||
"md","step-05-patterns","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-05-patterns.md","3c80aba507aa46893ef43f07c5c321b985632ef57abc82d5ee93c3d9c2911134"
|
||||
"md","step-05-present","bmm","bmm/4-implementation/bmad-quick-dev/step-05-present.md","ea49ce8703d4946ec54a59ff428d52ad64ab2649f49c43230bec1f3d09e85b55"
|
||||
"md","step-05-technical-trends","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-05-technical-trends.md","fd6c577010171679f630805eb76e09daf823c2b9770eb716986d01f351ce1fb4"
|
||||
"md","step-05-wrapup","bmm","bmm/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md","c2d4dbc17fed4f8ecbfcb09a2f9c77f0d4562bd27397270e2c27ba37cf4fe073"
|
||||
"md","step-06-design-system","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-06-design-system.md","1c71e452916c5b9ed000af4dd1b83954ae16887463c73776251e1e734e7d7641"
|
||||
"md","step-06-final-assessment","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/steps/step-06-final-assessment.md","1b9c8c2a21950f85e052e05309bfcb3bfe38157ba6fca402ccf37444e7f1cee3"
|
||||
"md","step-06-research-completion","bmm","bmm/1-analysis/research/bmad-market-research/steps/step-06-research-completion.md","4f5d3158a8462656d6959d29544ed5c92cb925641e2205e47af05c124fcd7666"
|
||||
"md","step-06-research-synthesis","bmm","bmm/1-analysis/research/bmad-domain-research/domain-steps/step-06-research-synthesis.md","805f2cb7005a4fb0c4da52ea38d45de863f3e840e0f2de1456c414831cdbdf59"
|
||||
"md","step-06-research-synthesis","bmm","bmm/1-analysis/research/bmad-technical-research/technical-steps/step-06-research-synthesis.md","64d9303ef0c9ce839dc3b2d72e94e3e5b4673e82dc7fc9f1571bd0b03b3a43a7"
|
||||
"md","step-06-structure","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-06-structure.md","f8333ca290b62849c1e2eb2f770b46705b09fe0322217b699b13be047efdd03e"
|
||||
"md","step-07-defining-experience","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-07-defining-experience.md","17f78d679a187cfb703c2cd30eea84d9dd683f3708d24885421239338eea4edd"
|
||||
"md","step-07-validation","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-07-validation.md","dde4cd93a3015011da9a8a3237aafa2d15d0935205686231e58bf7064f81cc28"
|
||||
"md","step-08-complete","bmm","bmm/3-solutioning/bmad-create-architecture/steps/step-08-complete.md","6466e5196be63e9e21b81d6fed45f1ff547d6ac739271201d6180febc857473d"
|
||||
"md","step-08-visual-foundation","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-08-visual-foundation.md","985b4da65435114529056f33ff583ec4d1b29feb3550494ae741b6dbb89798a9"
|
||||
"md","step-09-design-directions","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-09-design-directions.md","07962c637e69a612a904efccf6188b7f08c9e484d4d7369c74cd0de7da0cb1e3"
|
||||
"md","step-10-user-journeys","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-10-user-journeys.md","eabe15745e6b68df06833bca103c704d31094c8f070c84e35f1ee9b0c28d10bd"
|
||||
"md","step-11-component-strategy","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-11-component-strategy.md","52a1d0230160124496467ddbe26dd9cc4ae7d9afceaea987aad658e1bb195f59"
|
||||
"md","step-12-ux-patterns","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-12-ux-patterns.md","37215fe8ea33247e9a31b5f8b8fe3b36448d7f743c18803e4d5054c201348be8"
|
||||
"md","step-13-responsive-accessibility","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md","cd4d4e2a307b4cbc805c6954860c93c14a11b74b1e206c45ff89f8b81ab03a62"
|
||||
"md","step-14-complete","bmm","bmm/2-plan-workflows/bmad-create-ux-design/steps/step-14-complete.md","47880f82ada9f2edff8be144328f86fb683fd78aba64ec0ea9dfbff71732be00"
|
||||
"md","step-oneshot","bmm","bmm/4-implementation/bmad-quick-dev/step-oneshot.md","d68648848bb8277f5b762c3528c2aa42122268037b97d252a8f4faf4edb2830e"
|
||||
"md","sync-sprint-status","bmm","bmm/4-implementation/bmad-quick-dev/sync-sprint-status.md","c0f2fdb940984206a4b7aa2467fbcb7c4811f648634e43a488593974e9efff5e"
|
||||
"md","template","bmm","bmm/4-implementation/bmad-create-story/template.md","29ba697368d77e88e88d0e7ac78caf7a78785a7dcfc291082aa96a62948afb67"
|
||||
"md","ux-design-template","bmm","bmm/2-plan-workflows/bmad-create-ux-design/ux-design-template.md","ffa4b89376cd9db6faab682710b7ce755990b1197a8b3e16b17748656d1fca6a"
|
||||
"md","validate","bmm","bmm/2-plan-workflows/bmad-prd/references/validate.md","689387606f3df71641a619e42d2841c815de1e05f8ceb87d2198383e3cb0f98b"
|
||||
"md","validate-doc","bmm","bmm/1-analysis/bmad-agent-tech-writer/validate-doc.md","3b8d25f60be191716266726393f2d44b77262301b785a801631083b610d6acc5"
|
||||
"md","verdict","bmm","bmm/1-analysis/bmad-prfaq/references/verdict.md","1a1cbc34090114d3a2b928456edec1563b73d84fea66c58e98780bf680f172ec"
|
||||
"md","web-researcher","bmm","bmm/1-analysis/bmad-prfaq/agents/web-researcher.md","6e9127bb9bd3e4b15c701e4ced9eef328769262cd34eadc221bebe954c1f3aef"
|
||||
"md","write-document","bmm","bmm/1-analysis/bmad-agent-tech-writer/write-document.md","c0ddfd981f765b82cba0921dad331cd1fa32bacdeea1f02320edfd60a0ae7e6f"
|
||||
"toml","customize","bmm","bmm/1-analysis/bmad-agent-analyst/customize.toml","7191f4a60ada7dbabe083699b2461f35778af7688924dc6ce05911cf1ffc9054"
|
||||
"toml","customize","bmm","bmm/1-analysis/bmad-agent-tech-writer/customize.toml","bb828f2d26a136870099226f07c61297ce88ddd335823b7549592932bbe14a2e"
|
||||
"toml","customize","bmm","bmm/1-analysis/bmad-document-project/customize.toml","3bbb5044fafdd865a5fe0863df116a479f30de7fb95c9cf4213f0b87ba1ba924"
|
||||
"toml","customize","bmm","bmm/1-analysis/bmad-prfaq/customize.toml","8994f925bab65bd310e5ab38781286b6137f3e0c9e89f3ab8a2f480eb05e0de5"
|
||||
"toml","customize","bmm","bmm/1-analysis/bmad-product-brief/customize.toml","cdd7b417de5b280ab5bd3af65f69654b05980b5efb48077563827c16391f42d6"
|
||||
"toml","customize","bmm","bmm/1-analysis/research/bmad-domain-research/customize.toml","462ef14dc1e1f6d145fdfe25e315168fd1fa278ea5f87fe0fa86689234daec2f"
|
||||
"toml","customize","bmm","bmm/1-analysis/research/bmad-market-research/customize.toml","f47af9158893dd3f7ab81ac03cb07d8c76342eead61ec7c9a8a463066b7db9af"
|
||||
"toml","customize","bmm","bmm/1-analysis/research/bmad-technical-research/customize.toml","7f567fddb04674f817398addbc06e6d1620883d89dcc7ac643c5a56eef5ac337"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-agent-pm/customize.toml","afc2250de25241ac3577c9827e1ee62d8c7a1913de8eb23aadfffb0aadbadc37"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-agent-ux-designer/customize.toml","0d36c8ce31fe332770b2755a7e952dc102f184fb3275448bf6ac2ebc783ed03b"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-create-prd/customize.toml","0c990af4c0a755402abbef893850bd542744a8b6f36c0587c9f3fb6dab6bf5e7"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-create-ux-design/customize.toml","e51d02120e5e79788b183f1fb377f823f7626bac7702a6aa4dc6ddd0a3b00000"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-edit-prd/customize.toml","35cc2dfb3eda73a0ae4dc987cac58867b25c43ce5c5a1bf552e215826ede6c22"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-prd/customize.toml","c30f48939b558a0e7e4c0a4d4f6eb08e50bad4464ca64c652247d114a398d459"
|
||||
"toml","customize","bmm","bmm/2-plan-workflows/bmad-validate-prd/customize.toml","bfb3c8af5b81b2cdb9bb84f844f1b0dafc40d8c283effa6ad48db966b9446cef"
|
||||
"toml","customize","bmm","bmm/3-solutioning/bmad-agent-architect/customize.toml","32e8074896a569ce59e4db62ab3b4af396798b8fa50c67449a5d90c2a7a7e074"
|
||||
"toml","customize","bmm","bmm/3-solutioning/bmad-check-implementation-readiness/customize.toml","571d81087b7a6d914cf7c2f21aa26354eac385bb2f5cfeabc591892cbb98d036"
|
||||
"toml","customize","bmm","bmm/3-solutioning/bmad-create-architecture/customize.toml","618be49b0286f18dfb4b6bac3d96bf1cf062994fa5278cd327be9898e651b43c"
|
||||
"toml","customize","bmm","bmm/3-solutioning/bmad-create-epics-and-stories/customize.toml","da9e43605bc6aec83173050b5d37ede6aebfa9a17c5959a5029681d73e744ba3"
|
||||
"toml","customize","bmm","bmm/3-solutioning/bmad-generate-project-context/customize.toml","00b6e893b93a1be7c04408f1824dad72c2c43ee02ba29210725e34394bf8225e"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-agent-dev/customize.toml","b56c0af68e2234dc57d9ebe765049ebd9df9e24909d0d9b2b4b12ddefe5291a7"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-checkpoint-preview/customize.toml","1a1e0c3a8914adda3cec925eed51287e067c7f84120de64a8df53348e7b77de5"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-code-review/customize.toml","94ad662fdf3fda372c6e24b2cbda629d0b2aa851097d02afb5905c768b1a1cde"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-correct-course/customize.toml","78095d25afb5b2d4844956f61e9ac640a62d0245174fab9406e9c289f33fd84b"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-create-story/customize.toml","1204f6de064436922f2813069778eb4eaa5f1f945aa8ea5a5ffb87897a2ba8fe"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-dev-story/customize.toml","592fa7948c4614609a59b152518f518d35e0178d6aa7c22e169d78157fd9f5aa"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-investigate/customize.toml","7ec009a4a0a9b476de98de99938f58e319dc6f410b9176910c9ed725e80519e1"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-qa-generate-e2e-tests/customize.toml","04a99926b2d79318b927434c7344dc666f143026ed99c7fcc0d6e3d68263ae66"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-quick-dev/customize.toml","5ba463713de51762eb381be787cbd657932333b808be370d851fc3e40f139e2f"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-retrospective/customize.toml","0b032c342129732820ca2db386d2d5e26d033d8ac296388fc9f2e78765fab9fb"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-sprint-planning/customize.toml","f7bde2f792e8604f26122ee792c5493e270710296044b07680db7d90e886caf3"
|
||||
"toml","customize","bmm","bmm/4-implementation/bmad-sprint-status/customize.toml","96261fd227befaa4685b0a5091d3e85299d4ae8e4404176b42f5ef2e2fb501bd"
|
||||
"yaml","config","bmm","bmm/config.yaml","06e1233c58826b94feede08e24d01a7044ca8d0d25f4d3c26e5656dc3b46e257"
|
||||
"yaml","sprint-status-template","bmm","bmm/4-implementation/bmad-sprint-planning/sprint-status-template.yaml","deeec135d875b107618dd41278349689b5f3dcb5894d7509909417a570f46fd9"
|
||||
"toml","config","config.toml","config.toml","a5289f86cfb065a86da2b66e6e7af3f4ecf97d9ebe77236d9f346ae30ed75d1c"
|
||||
"toml","config.user","config.user.toml","config.user.toml","3d482b5bce4aa49577c42895f9a4d79a6859a9b6df2740b8f7829dcce50f142c"
|
||||
"csv","brain-methods","core","core/bmad-brainstorming/brain-methods.csv","0ab5878b1dbc9e3fa98cb72abfc3920a586b9e2b42609211bb0516eefd542039"
|
||||
"csv","methods","core","core/bmad-advanced-elicitation/methods.csv","e08b2e22fec700274982e37be608d6c3d1d4d0c04fa0bae05aa9dba2454e6141"
|
||||
"csv","module-help","core","core/module-help.csv","6677d8aaeaae3ed92185ea51094bfb08c5f2fb059faf88029da30d3313c36273"
|
||||
"md","compression-rules","core","core/bmad-distillator/resources/compression-rules.md","86e53d6a2072b379864766681d1cc4e1aad3d4428ecca8c46010f7364da32724"
|
||||
"md","distillate-compressor","core","core/bmad-distillator/agents/distillate-compressor.md","c00da33b39a43207a224c4043d1aa4158e90e41ab421fff0ea7cc55beec81ef8"
|
||||
"md","distillate-format-reference","core","core/bmad-distillator/resources/distillate-format-reference.md","47b6734ab8c327215e3578f98c3b7ef3772332dcf6710d92f5599ffb4d03a085"
|
||||
"md","round-trip-reconstructor","core","core/bmad-distillator/agents/round-trip-reconstructor.md","47c83f4a37249ddac38460d8c95d162f6fc175a8919888e8090aed71bd9383bc"
|
||||
"md","SKILL","core","core/bmad-advanced-elicitation/SKILL.md","705cc827cab892855d07dcc926f5c9f749c3abab33e915d6ac1574aec174896b"
|
||||
"md","SKILL","core","core/bmad-brainstorming/SKILL.md","f4a2c22b40ed34cdbd3282dd6161a3b869902f3bc75b58e181fc9faf78eedd9d"
|
||||
"md","SKILL","core","core/bmad-customize/SKILL.md","6461073f9322f5f2ec64477d6ae5e79c601e36e3160e492fef4bc2d928d4a9a8"
|
||||
"md","SKILL","core","core/bmad-distillator/SKILL.md","756ee0706ff6b8a3d5726b465e81ba244e4eaeba21b7de0d2390473acebb5ddc"
|
||||
"md","SKILL","core","core/bmad-editorial-review-prose/SKILL.md","b3687fe80567378627bc2a0c5034ae8d65dfeedcf2b6c90da077f4feca462d0c"
|
||||
"md","SKILL","core","core/bmad-editorial-review-structure/SKILL.md","164444359d74f695a84faf7ea558d0eef39c75561e6b26669f97a165c6f75538"
|
||||
"md","SKILL","core","core/bmad-help/SKILL.md","a766c6bd76bcfc4a49a683417440d39978a2e10bb5618dfd469fff03f96b4b4d"
|
||||
"md","SKILL","core","core/bmad-index-docs/SKILL.md","a855d7060414e73ca4fe8e1a3e1cc4d0f2ce394846e52340bdf5a1317e0d234a"
|
||||
"md","SKILL","core","core/bmad-party-mode/SKILL.md","bd13fd6cb0b0c61d215e4c394cf4b7cac3a465394c3918f4dc14910513ddb898"
|
||||
"md","SKILL","core","core/bmad-review-adversarial-general/SKILL.md","7bffc39e6dba4d9123648c5d4d79e17c3c5b1efbd927c3fe0026c2dbb8d99cff"
|
||||
"md","SKILL","core","core/bmad-review-edge-case-hunter/SKILL.md","f49ed9976f46b4cefa1fc8b4f0a495f16089905e6a7bbf4ce73b8f05c9ae3ee6"
|
||||
"md","SKILL","core","core/bmad-shard-doc/SKILL.md","3a1538536514725fd4f31aded280ee56b9645fc61d114fd94aacb3ac52304e52"
|
||||
"md","splitting-strategy","core","core/bmad-distillator/resources/splitting-strategy.md","26d3ed05f912cf99ff9ebe2353f2d84d70e3e852e23a32b1215c13416ad708b5"
|
||||
"md","step-01-session-setup","core","core/bmad-brainstorming/steps/step-01-session-setup.md","7fd2aed9527ccdf35fc86bd4c9b27b4a530b5cfdfb90ae2b7385d3185bcd60bc"
|
||||
"md","step-01b-continue","core","core/bmad-brainstorming/steps/step-01b-continue.md","49f8d78290291f974432bc8e8fce340de58ed62aa946e9e3182858bf63829920"
|
||||
"md","step-02a-user-selected","core","core/bmad-brainstorming/steps/step-02a-user-selected.md","7ff3bca27286d17902ecea890494599796633e24a25ea6b31bbd6c3d2e54eba2"
|
||||
"md","step-02b-ai-recommended","core","core/bmad-brainstorming/steps/step-02b-ai-recommended.md","cb77b810e0c98e080b4378999f0e250bacba4fb74c1bcb0a144cffe9989d2cbd"
|
||||
"md","step-02c-random-selection","core","core/bmad-brainstorming/steps/step-02c-random-selection.md","91c6e16213911a231a41b1a55be7c939e7bbcd1463bd49cb03b5b669a90c0868"
|
||||
"md","step-02d-progressive-flow","core","core/bmad-brainstorming/steps/step-02d-progressive-flow.md","6b6fbbd34bcf334d79f09e8c36ed3c9d55ddd3ebb8f8f77aa892643d1a4e3436"
|
||||
"md","step-03-technique-execution","core","core/bmad-brainstorming/steps/step-03-technique-execution.md","b97afefd4ccc5234e554a3dfc5555337269ce171e730b250c756718235e9df60"
|
||||
"md","step-04-idea-organization","core","core/bmad-brainstorming/steps/step-04-idea-organization.md","acb7eb6a54161213bb916cabf7d0d5084316704e792a880968fc340855cdcbbb"
|
||||
"md","template","core","core/bmad-brainstorming/template.md","5c99d76963eb5fc21db96c5a68f39711dca7c6ed30e4f7d22aedee9e8bb964f9"
|
||||
"md","workflow","core","core/bmad-brainstorming/workflow.md","74c87846a5cda7a4534ea592ea3125a8d8a1a88d19c94f5f4481fb28d0d16bf2"
|
||||
"py","analyze_sources","core","core/bmad-distillator/scripts/analyze_sources.py","31e2a8441c3c43c2536739c580cdef6abecb18ff20e7447f42dd868875783166"
|
||||
"py","list_customizable_skills","core","core/bmad-customize/scripts/list_customizable_skills.py","8787f542930b927789e7fdf12bc5a67ff08e19865903a5ad05ff2cc8fc426b66"
|
||||
"py","test_analyze_sources","core","core/bmad-distillator/scripts/tests/test_analyze_sources.py","d90525311f8010aaf8d7d9212a370468a697866190bae78c35d0aae9b7f23fdf"
|
||||
"py","test_list_customizable_skills","core","core/bmad-customize/scripts/tests/test_list_customizable_skills.py","b55fc2e454f245753874f359c18ade9f3ad04debd66176c6e6bf3e403ca9c812"
|
||||
"yaml","config","core","core/config.yaml","5fc4b2e796091877a445427b4e3a1f403daaa177193277b6fda26180a88f71ee"
|
||||
"file",".gitignore","custom","custom/.gitignore","973b03a33f142c22cf9b65be285bebadd85790b6b55be04637d2f8c716f58fab"
|
||||
"py","resolve_config","scripts","scripts/resolve_config.py","8e326149d9170477ecc21aa2aa2389d8fbaa5d1cd95db2de2ad33029ce8ae528"
|
||||
"py","resolve_customization","scripts","scripts/resolve_customization.py","6dbf36a2fea13392426fdbaf4f074b6d9b93488a964d2d1bff2a5c1c3a1d506e"
|
||||
|
@@ -0,0 +1,21 @@
|
||||
installation:
|
||||
version: 6.7.1
|
||||
installDate: 2026-05-25T16:02:56.785Z
|
||||
lastUpdated: 2026-05-25T16:02:56.785Z
|
||||
modules:
|
||||
- name: core
|
||||
version: 6.7.1
|
||||
installDate: 2026-05-25T16:02:56.768Z
|
||||
lastUpdated: 2026-05-25T16:02:56.784Z
|
||||
source: built-in
|
||||
npmPackage: null
|
||||
repoUrl: null
|
||||
- name: bmm
|
||||
version: 6.7.1
|
||||
installDate: 2026-05-25T16:02:56.751Z
|
||||
lastUpdated: 2026-05-25T16:02:56.785Z
|
||||
source: built-in
|
||||
npmPackage: null
|
||||
repoUrl: null
|
||||
ides:
|
||||
- claude-code
|
||||
@@ -0,0 +1,45 @@
|
||||
canonicalId,name,description,module,path
|
||||
"bmad-advanced-elicitation","bmad-advanced-elicitation","Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team.","core","_bmad/core/bmad-advanced-elicitation/SKILL.md"
|
||||
"bmad-brainstorming","bmad-brainstorming","Facilitate interactive brainstorming sessions using diverse creative techniques and ideation methods. Use when the user says help me brainstorm or help me ideate.","core","_bmad/core/bmad-brainstorming/SKILL.md"
|
||||
"bmad-customize","bmad-customize","Authors and updates customization overrides for installed BMad skills. Use when the user says 'customize bmad', 'override a skill', 'change agent behavior', or 'customize a workflow'.","core","_bmad/core/bmad-customize/SKILL.md"
|
||||
"bmad-distillator","bmad-distillator","Lossless LLM-optimized compression of source documents. Use when the user requests to 'distill documents' or 'create a distillate'.","core","_bmad/core/bmad-distillator/SKILL.md"
|
||||
"bmad-editorial-review-prose","bmad-editorial-review-prose","Clinical copy-editor that reviews text for communication issues. Use when user says review for prose or improve the prose","core","_bmad/core/bmad-editorial-review-prose/SKILL.md"
|
||||
"bmad-editorial-review-structure","bmad-editorial-review-structure","Structural editor that proposes cuts, reorganization, and simplification while preserving comprehension. Use when user requests structural review or editorial review of structure","core","_bmad/core/bmad-editorial-review-structure/SKILL.md"
|
||||
"bmad-help","bmad-help","Analyzes current state and user query to answer BMad questions or recommend the next skill(s) to use. Use when user asks for help, bmad help, what to do next, or what to start with in BMad.","core","_bmad/core/bmad-help/SKILL.md"
|
||||
"bmad-index-docs","bmad-index-docs","Generates or updates an index.md to reference all docs in the folder. Use if user requests to create or update an index of all files in a specific folder","core","_bmad/core/bmad-index-docs/SKILL.md"
|
||||
"bmad-party-mode","bmad-party-mode","Orchestrates group discussions between installed BMAD agents, enabling natural multi-agent conversations where each agent is a real subagent with independent thinking. Use when user requests party mode, wants multiple agent perspectives, group discussion, roundtable, or multi-agent conversation about their project.","core","_bmad/core/bmad-party-mode/SKILL.md"
|
||||
"bmad-review-adversarial-general","bmad-review-adversarial-general","Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something","core","_bmad/core/bmad-review-adversarial-general/SKILL.md"
|
||||
"bmad-review-edge-case-hunter","bmad-review-edge-case-hunter","Walk every branching path and boundary condition in content, report only unhandled edge cases. Orthogonal to adversarial review - method-driven not attitude-driven. Use when you need exhaustive edge-case analysis of code, specs, or diffs.","core","_bmad/core/bmad-review-edge-case-hunter/SKILL.md"
|
||||
"bmad-shard-doc","bmad-shard-doc","Splits large markdown documents into smaller, organized files based on level 2 (default) sections. Use if the user says perform shard document","core","_bmad/core/bmad-shard-doc/SKILL.md"
|
||||
"bmad-agent-analyst","bmad-agent-analyst","Strategic business analyst and requirements expert. Use when the user asks to talk to Mary or requests the business analyst.","bmm","_bmad/bmm/1-analysis/bmad-agent-analyst/SKILL.md"
|
||||
"bmad-agent-tech-writer","bmad-agent-tech-writer","Technical documentation specialist and knowledge curator. Use when the user asks to talk to Paige or requests the tech writer.","bmm","_bmad/bmm/1-analysis/bmad-agent-tech-writer/SKILL.md"
|
||||
"bmad-document-project","bmad-document-project","Document brownfield projects for AI context. Use when the user says ""document this project"" or ""generate project docs""","bmm","_bmad/bmm/1-analysis/bmad-document-project/SKILL.md"
|
||||
"bmad-prfaq","bmad-prfaq","Working Backwards PRFAQ challenge to forge product concepts. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'.","bmm","_bmad/bmm/1-analysis/bmad-prfaq/SKILL.md"
|
||||
"bmad-product-brief","bmad-product-brief","Create, update, or validate a product brief. Use when the user wants help producing, editing, or validating a brief.","bmm","_bmad/bmm/1-analysis/bmad-product-brief/SKILL.md"
|
||||
"bmad-domain-research","bmad-domain-research","Conduct domain and industry research. Use when the user says wants to do domain research for a topic or industry","bmm","_bmad/bmm/1-analysis/research/bmad-domain-research/SKILL.md"
|
||||
"bmad-market-research","bmad-market-research","Conduct market research on competition and customers. Use when the user says they need market research","bmm","_bmad/bmm/1-analysis/research/bmad-market-research/SKILL.md"
|
||||
"bmad-technical-research","bmad-technical-research","Conduct technical research on technologies and architecture. Use when the user says they would like to do or produce a technical research report","bmm","_bmad/bmm/1-analysis/research/bmad-technical-research/SKILL.md"
|
||||
"bmad-agent-pm","bmad-agent-pm","Product manager for PRD creation and requirements discovery. Use when the user asks to talk to John or requests the product manager.","bmm","_bmad/bmm/2-plan-workflows/bmad-agent-pm/SKILL.md"
|
||||
"bmad-agent-ux-designer","bmad-agent-ux-designer","UX designer and UI specialist. Use when the user asks to talk to Sally or requests the UX designer.","bmm","_bmad/bmm/2-plan-workflows/bmad-agent-ux-designer/SKILL.md"
|
||||
"bmad-create-prd","bmad-create-prd","DEPRECATED — consolidated into bmad-prd create intent - this skill will be removed in v7 in favor of `bmad-prd`.","bmm","_bmad/bmm/2-plan-workflows/bmad-create-prd/SKILL.md"
|
||||
"bmad-create-ux-design","bmad-create-ux-design","Plan UX patterns and design specifications. Use when the user says ""lets create UX design"" or ""create UX specifications"" or ""help me plan the UX""","bmm","_bmad/bmm/2-plan-workflows/bmad-create-ux-design/SKILL.md"
|
||||
"bmad-edit-prd","bmad-edit-prd","DEPRECATED — consolidated into bmad-prd update intent - this skill will be removed in v7 in favor of `bmad-prd`.","bmm","_bmad/bmm/2-plan-workflows/bmad-edit-prd/SKILL.md"
|
||||
"bmad-prd","bmad-prd","Create, update, or validate a PRD. Use when the user wants help producing, editing, or validating a PRD.","bmm","_bmad/bmm/2-plan-workflows/bmad-prd/SKILL.md"
|
||||
"bmad-validate-prd","bmad-validate-prd","DEPRECATED — consolidated into bmad-prd validate intent - this skill will be removed in v7 in favor of `bmad-prd`.","bmm","_bmad/bmm/2-plan-workflows/bmad-validate-prd/SKILL.md"
|
||||
"bmad-agent-architect","bmad-agent-architect","System architect and technical design leader. Use when the user asks to talk to Winston or requests the architect.","bmm","_bmad/bmm/3-solutioning/bmad-agent-architect/SKILL.md"
|
||||
"bmad-check-implementation-readiness","bmad-check-implementation-readiness","Validate PRD, UX, Architecture and Epics specs are complete. Use when the user says ""check implementation readiness"".","bmm","_bmad/bmm/3-solutioning/bmad-check-implementation-readiness/SKILL.md"
|
||||
"bmad-create-architecture","bmad-create-architecture","Create architecture solution design decisions for AI agent consistency. Use when the user says ""lets create architecture"" or ""create technical architecture"" or ""create a solution design""","bmm","_bmad/bmm/3-solutioning/bmad-create-architecture/SKILL.md"
|
||||
"bmad-create-epics-and-stories","bmad-create-epics-and-stories","Break requirements into epics and user stories. Use when the user says ""create the epics and stories list""","bmm","_bmad/bmm/3-solutioning/bmad-create-epics-and-stories/SKILL.md"
|
||||
"bmad-generate-project-context","bmad-generate-project-context","Create project-context.md with AI rules. Use when the user says ""generate project context"" or ""create project context""","bmm","_bmad/bmm/3-solutioning/bmad-generate-project-context/SKILL.md"
|
||||
"bmad-agent-dev","bmad-agent-dev","Senior software engineer for story execution and code implementation. Use when the user asks to talk to Amelia or requests the developer agent.","bmm","_bmad/bmm/4-implementation/bmad-agent-dev/SKILL.md"
|
||||
"bmad-checkpoint-preview","bmad-checkpoint-preview","LLM-assisted human-in-the-loop review. Make sense of a change, focus attention where it matters, test. Use when the user says ""checkpoint"", ""human review"", or ""walk me through this change"".","bmm","_bmad/bmm/4-implementation/bmad-checkpoint-preview/SKILL.md"
|
||||
"bmad-code-review","bmad-code-review","Review code changes adversarially using parallel review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor) with structured triage into actionable categories. Use when the user says ""run code review"" or ""review this code""","bmm","_bmad/bmm/4-implementation/bmad-code-review/SKILL.md"
|
||||
"bmad-correct-course","bmad-correct-course","Manage significant changes during sprint execution. Use when the user says ""correct course"" or ""propose sprint change""","bmm","_bmad/bmm/4-implementation/bmad-correct-course/SKILL.md"
|
||||
"bmad-create-story","bmad-create-story","Creates a dedicated story file with all the context the agent will need to implement it later. Use when the user says ""create the next story"" or ""create story [story identifier]""","bmm","_bmad/bmm/4-implementation/bmad-create-story/SKILL.md"
|
||||
"bmad-dev-story","bmad-dev-story","Execute story implementation following a context filled story spec file. Use when the user says ""dev this story [story file]"" or ""implement the next story in the sprint plan""","bmm","_bmad/bmm/4-implementation/bmad-dev-story/SKILL.md"
|
||||
"bmad-investigate","bmad-investigate","Forensic case investigation with evidence-graded findings, calibrated to the input. Use when the user asks to investigate a bug, trace what caused an incident, walk through unfamiliar code, or build a mental model of a code area before working on it.","bmm","_bmad/bmm/4-implementation/bmad-investigate/SKILL.md"
|
||||
"bmad-qa-generate-e2e-tests","bmad-qa-generate-e2e-tests","Generate end to end automated tests for existing features. Use when the user says ""create qa automated tests for [feature]""","bmm","_bmad/bmm/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md"
|
||||
"bmad-quick-dev","bmad-quick-dev","Implements any user intent, requirement, story, bug fix or change request by producing clean working code artifacts that follow the project's existing architecture, patterns and conventions. Use when the user wants to build, fix, tweak, refactor, add or modify any code, component or feature.","bmm","_bmad/bmm/4-implementation/bmad-quick-dev/SKILL.md"
|
||||
"bmad-retrospective","bmad-retrospective","Post-epic review to extract lessons and assess success. Use when the user says ""run a retrospective"" or ""lets retro the epic [epic]""","bmm","_bmad/bmm/4-implementation/bmad-retrospective/SKILL.md"
|
||||
"bmad-sprint-planning","bmad-sprint-planning","Generate sprint status tracking from epics. Use when the user says ""run sprint planning"" or ""generate sprint plan""","bmm","_bmad/bmm/4-implementation/bmad-sprint-planning/SKILL.md"
|
||||
"bmad-sprint-status","bmad-sprint-status","Summarize sprint status and surface risks. Use when the user says ""check sprint status"" or ""show sprint status""","bmm","_bmad/bmm/4-implementation/bmad-sprint-status/SKILL.md"
|
||||
|
@@ -0,0 +1,16 @@
|
||||
# BMM Module Configuration
|
||||
# Generated by BMAD installer
|
||||
# Version: 6.7.1
|
||||
# Date: 2026-05-25T16:02:56.772Z
|
||||
|
||||
user_skill_level: intermediate
|
||||
planning_artifacts: "{project-root}/_bmad-output/planning-artifacts"
|
||||
implementation_artifacts: "{project-root}/_bmad-output/implementation-artifacts"
|
||||
project_knowledge: "{project-root}/docs"
|
||||
|
||||
# Core Configuration Values
|
||||
user_name: Dejancosic
|
||||
project_name: projectmanagement
|
||||
communication_language: german
|
||||
document_output_language: german
|
||||
output_folder: "{project-root}/_bmad-output"
|
||||
@@ -0,0 +1,32 @@
|
||||
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
|
||||
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,,anytime,,,false,project-knowledge,*
|
||||
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,,anytime,,,false,output_folder,project context
|
||||
BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,,anytime,,,false,implementation_artifacts,spec and project implementation
|
||||
BMad Method,bmad-correct-course,Correct Course,CC,Navigate significant changes. May recommend start over update PRD redo architecture sprint planning or correct epics and stories.,,,anytime,,,false,planning_artifacts,change proposal
|
||||
BMad Method,bmad-agent-tech-writer,Write Document,WD,"Describe in detail what you want, and the agent will follow documentation best practices. Multi-turn conversation with subprocess for research/review.",write,,anytime,,,false,project-knowledge,document
|
||||
BMad Method,bmad-agent-tech-writer,Update Standards,US,Update agent memory documentation-standards.md with your specific preferences if you discover missing document conventions.,update-standards,,anytime,,,false,_bmad/_memory/tech-writer-sidecar,standards
|
||||
BMad Method,bmad-agent-tech-writer,Mermaid Generate,MG,Create a Mermaid diagram based on user description. Will suggest diagram types if not specified.,mermaid,,anytime,,,false,planning_artifacts,mermaid diagram
|
||||
BMad Method,bmad-agent-tech-writer,Validate Document,VD,Review the specified document against documentation standards and best practices. Returns specific actionable improvement suggestions organized by priority.,validate,[path],anytime,,,false,planning_artifacts,validation report
|
||||
BMad Method,bmad-agent-tech-writer,Explain Concept,EC,Create clear technical explanations with examples and diagrams for complex concepts.,explain,[topic],anytime,,,false,project_knowledge,explanation
|
||||
BMad Method,bmad-brainstorming,Brainstorm Project,BP,Expert guided facilitation through a single or multiple techniques.,,,1-analysis,,,false,planning_artifacts,brainstorming session
|
||||
BMad Method,bmad-market-research,Market Research,MR,Market analysis competitive landscape customer needs and trends.,,,1-analysis,,,false,planning_artifacts|project-knowledge,research documents
|
||||
BMad Method,bmad-domain-research,Domain Research,DR,Industry domain deep dive subject matter expertise and terminology.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||
BMad Method,bmad-technical-research,Technical Research,TR,Technical feasibility architecture options and implementation approaches.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||
BMad Method,bmad-product-brief,Create Brief,CB,An expert guided experience to nail down your product idea in a brief. a gentler approach than PRFAQ when you are already sure of your concept and nothing will sway you.,,-A,1-analysis,,,false,planning_artifacts,product brief
|
||||
BMad Method,bmad-prfaq,PRFAQ Challenge,WB,Working Backwards guided experience to forge and stress-test your product concept to ensure you have a great product that users will love and need through the PRFAQ gauntlet to determine feasibility and alignment with user needs. alternative to product brief.,,-H,1-analysis,,,false,planning_artifacts,prfaq document
|
||||
BMad Method,bmad-prd,Create Edit and Review PRD,PRD,"Facilitated PRD workflow — create a new PRD via coached discovery, update an existing one against a change signal, or validate a finished PRD against a checklist with an HTML findings report.",,,2-planning,bmad-product-brief,,true,planning_artifacts,prd
|
||||
BMad Method,bmad-create-ux-design,Create UX,CU,"Guidance through realizing the plan for your UX, strongly recommended if a UI is a primary piece of the proposed project.",,,2-planning,bmad-prd,,false,planning_artifacts,ux design
|
||||
BMad Method,bmad-create-architecture,Create Architecture,CA,Guided workflow to document technical decisions.,,,3-solutioning,,,true,planning_artifacts,architecture
|
||||
BMad Method,bmad-create-epics-and-stories,Create Epics and Stories,CE,,,,3-solutioning,bmad-create-architecture,,true,planning_artifacts,epics and stories
|
||||
BMad Method,bmad-check-implementation-readiness,Check Implementation Readiness,IR,Ensure PRD UX Architecture and Epics Stories are aligned.,,,3-solutioning,bmad-create-epics-and-stories,,true,planning_artifacts,readiness report
|
||||
BMad Method,bmad-sprint-planning,Sprint Planning,SP,Kicks off implementation by producing a plan the implementation agents will follow in sequence for every story.,,,4-implementation,,,true,implementation_artifacts,sprint status
|
||||
BMad Method,bmad-sprint-status,Sprint Status,SS,Anytime: Summarize sprint status and route to next workflow.,,,4-implementation,bmad-sprint-planning,,false,,
|
||||
BMad Method,bmad-create-story,Create Story,CS,Story cycle start: Prepare first found story in the sprint plan that is next or a specific epic/story designation.,create,,4-implementation,bmad-sprint-planning,bmad-create-story:validate,true,implementation_artifacts,story
|
||||
BMad Method,bmad-create-story,Validate Story,VS,Validates story readiness and completeness before development work begins.,validate,,4-implementation,bmad-create-story:create,bmad-dev-story,false,implementation_artifacts,story validation report
|
||||
BMad Method,bmad-dev-story,Dev Story,DS,Story cycle: Execute story implementation tasks and tests then CR then back to DS if fixes needed.,,,4-implementation,bmad-create-story:validate,,true,,
|
||||
BMad Method,bmad-code-review,Code Review,CR,Story cycle: If issues back to DS if approved then next CS or ER if epic complete.,,,4-implementation,bmad-dev-story,,false,,
|
||||
BMad Method,bmad-checkpoint-preview,Checkpoint,CK,Guided walkthrough of a change from purpose and context into details. Use for human review of commits branches or PRs.,,,4-implementation,,,false,,
|
||||
BMad Method,bmad-qa-generate-e2e-tests,QA Automation Test,QA,Generate automated API and E2E tests for implemented code. NOT for code review or story validation — use CR for that.,,,4-implementation,bmad-dev-story,,false,implementation_artifacts,test suite
|
||||
BMad Method,bmad-retrospective,Retrospective,ER,Optional at epic end: Review completed work lessons learned and next epic or if major issues consider CC.,,,4-implementation,bmad-code-review,,false,implementation_artifacts,retrospective
|
||||
BMad Method,bmad-investigate,Investigate,IN,Forensic case investigation calibrated to the input. Evidence-graded analysis with hypothesis tracking. Produces a structured case file.,,4-implementation,,,false,implementation_artifacts,investigation report
|
||||
|
@@ -0,0 +1,69 @@
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Installer-managed. Regenerated on every install — treat as read-only.
|
||||
#
|
||||
# Direct edits to this file will be overwritten on the next install.
|
||||
# To change an install answer durably, re-run the installer (your prior
|
||||
# answers are remembered as defaults). To pin a value regardless of
|
||||
# install answers, or to add custom agents / override descriptors, use:
|
||||
# _bmad/custom/config.toml (team, committed)
|
||||
# _bmad/custom/config.user.toml (personal, gitignored)
|
||||
# Those files are never touched by the installer.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[core]
|
||||
project_name = "projectmanagement"
|
||||
document_output_language = "german"
|
||||
output_folder = "{project-root}/_bmad-output"
|
||||
|
||||
[modules.bmm]
|
||||
planning_artifacts = "{project-root}/_bmad-output/planning-artifacts"
|
||||
implementation_artifacts = "{project-root}/_bmad-output/implementation-artifacts"
|
||||
project_knowledge = "{project-root}/docs"
|
||||
|
||||
[agents.bmad-agent-analyst]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "Mary"
|
||||
title = "Business Analyst"
|
||||
icon = "📊"
|
||||
description = "Channels Porter's strategic rigor and Minto's Pyramid Principle, grounds every finding in verifiable evidence, represents every stakeholder voice. Speaks like a treasure hunter narrating the find: thrilled by every clue, precise once the pattern emerges."
|
||||
|
||||
[agents.bmad-agent-tech-writer]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "Paige"
|
||||
title = "Technical Writer"
|
||||
icon = "📚"
|
||||
description = "Master of CommonMark, DITA, and OpenAPI; turns complex concepts into accessible structured docs, favors diagrams over walls of text, every word earning its place. Speaks like the patient teacher you wish you'd had, using analogies that make complex things feel simple."
|
||||
|
||||
[agents.bmad-agent-pm]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "John"
|
||||
title = "Product Manager"
|
||||
icon = "📋"
|
||||
description = "Drives Jobs-to-be-Done over template filling, user value first, technical feasibility is a constraint not the driver. Speaks like a detective interrogating a cold case: short questions, sharper follow-ups, every 'why?' tightening the net."
|
||||
|
||||
[agents.bmad-agent-ux-designer]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "Sally"
|
||||
title = "UX Designer"
|
||||
icon = "🎨"
|
||||
description = "Balances empathy with edge-case rigor, starts simple and evolves through feedback, every decision serves a genuine user need. Speaks like a filmmaker pitching the scene before the code exists, painting user stories that make you feel the problem."
|
||||
|
||||
[agents.bmad-agent-architect]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "Winston"
|
||||
title = "System Architect"
|
||||
icon = "🏗️"
|
||||
description = "Favors boring technology for stability, developer productivity as architecture, ties every decision to business value. Speaks like a seasoned engineer at the whiteboard: measured, always laying out trade-offs rather than verdicts."
|
||||
|
||||
[agents.bmad-agent-dev]
|
||||
module = "bmm"
|
||||
team = "software-development"
|
||||
name = "Amelia"
|
||||
title = "Senior Software Engineer"
|
||||
icon = "💻"
|
||||
description = "Test-first discipline (red, green, refactor), 100% pass before review, no fluff all precision. Speaks like a terminal prompt: exact file paths, AC IDs, and commit-message brevity — every statement citable."
|
||||
@@ -0,0 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Installer-managed. Regenerated on every install — treat as read-only.
|
||||
# Holds install answers scoped to YOU personally.
|
||||
#
|
||||
# Direct edits to this file will be overwritten on the next install.
|
||||
# To change an answer durably, re-run the installer (your prior answers
|
||||
# are remembered as defaults). For pinned overrides or custom sections
|
||||
# the installer does not know about, use _bmad/custom/config.user.toml
|
||||
# — it is never touched by the installer.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[core]
|
||||
user_name = "Dejancosic"
|
||||
communication_language = "german"
|
||||
|
||||
[modules.bmm]
|
||||
user_skill_level = "intermediate"
|
||||
@@ -0,0 +1,10 @@
|
||||
# CORE Module Configuration
|
||||
# Generated by BMAD installer
|
||||
# Version: 6.7.1
|
||||
# Date: 2026-05-25T16:02:56.772Z
|
||||
|
||||
user_name: Dejancosic
|
||||
project_name: projectmanagement
|
||||
communication_language: german
|
||||
document_output_language: german
|
||||
output_folder: "{project-root}/_bmad-output"
|
||||
@@ -0,0 +1,13 @@
|
||||
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
|
||||
Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
|
||||
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,,anytime,,,false,,
|
||||
Core,bmad-help,BMad Help,BH,,,,anytime,,,false,,
|
||||
Core,bmad-index-docs,Index Docs,ID,Use when LLM needs to understand available docs without loading everything.,,,anytime,,,false,,
|
||||
Core,bmad-shard-doc,Shard Document,SD,Use when doc becomes too large (>500 lines) to manage effectively.,,[path],anytime,,,false,,
|
||||
Core,bmad-editorial-review-prose,Editorial Review - Prose,EP,Use after drafting to polish written content.,,[path],anytime,,,false,report located with target document,three-column markdown table with suggested fixes
|
||||
Core,bmad-editorial-review-structure,Editorial Review - Structure,ES,Use when doc produced from multiple subprocesses or needs structural improvement.,,[path],anytime,,,false,report located with target document,
|
||||
Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",,[path],anytime,,,false,,
|
||||
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,,
|
||||
Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s)
|
||||
Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files
|
||||
|
@@ -0,0 +1 @@
|
||||
*.user.toml
|
||||
@@ -0,0 +1,7 @@
|
||||
# Team / enterprise overrides for _bmad/config.toml.
|
||||
# Committed to the repo — applies to every developer on the project.
|
||||
# Tables deep-merge over base config; keyed entries merge by key.
|
||||
# Example: override an agent descriptor, or add a new agent.
|
||||
#
|
||||
# [agents.bmad-agent-pm]
|
||||
# description = "Prefers short, bulleted PRDs over narrative drafts."
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolve BMad's central config using four-layer TOML merge.
|
||||
|
||||
Reads from four layers (highest priority last):
|
||||
1. {project-root}/_bmad/config.toml (installer-owned team)
|
||||
2. {project-root}/_bmad/config.user.toml (installer-owned user)
|
||||
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
|
||||
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
|
||||
|
||||
Outputs merged JSON to stdout. Errors go to stderr.
|
||||
|
||||
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`,
|
||||
no virtualenv — plain `python3` is sufficient.
|
||||
|
||||
python3 resolve_config.py --project-root /abs/path/to/project
|
||||
python3 resolve_config.py --project-root ... --key core
|
||||
python3 resolve_config.py --project-root ... --key agents
|
||||
|
||||
Merge rules (same as resolve_customization.py):
|
||||
- Scalars: override wins
|
||||
- Tables: deep merge
|
||||
- Arrays of tables where every item shares `code` or `id`: merge by that key
|
||||
- All other arrays: append
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
sys.stderr.write(
|
||||
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n"
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
_KEYED_MERGE_FIELDS = ("code", "id")
|
||||
|
||||
|
||||
def load_toml(file_path: Path, required: bool = False) -> dict:
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
sys.stderr.write(f"error: required config file not found: {file_path}\n")
|
||||
sys.exit(1)
|
||||
return {}
|
||||
try:
|
||||
with file_path.open("rb") as f:
|
||||
parsed = tomllib.load(f)
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return parsed
|
||||
except tomllib.TOMLDecodeError as error:
|
||||
level = "error" if required else "warning"
|
||||
sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n")
|
||||
if required:
|
||||
sys.exit(1)
|
||||
return {}
|
||||
except OSError as error:
|
||||
level = "error" if required else "warning"
|
||||
sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n")
|
||||
if required:
|
||||
sys.exit(1)
|
||||
return {}
|
||||
|
||||
|
||||
def _detect_keyed_merge_field(items):
|
||||
if not items or not all(isinstance(item, dict) for item in items):
|
||||
return None
|
||||
for candidate in _KEYED_MERGE_FIELDS:
|
||||
if all(item.get(candidate) is not None for item in items):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _merge_by_key(base, override, key_name):
|
||||
result = []
|
||||
index_by_key = {}
|
||||
for item in base:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get(key_name) is not None:
|
||||
index_by_key[item[key_name]] = len(result)
|
||||
result.append(dict(item))
|
||||
for item in override:
|
||||
if not isinstance(item, dict):
|
||||
result.append(item)
|
||||
continue
|
||||
key = item.get(key_name)
|
||||
if key is not None and key in index_by_key:
|
||||
result[index_by_key[key]] = dict(item)
|
||||
else:
|
||||
if key is not None:
|
||||
index_by_key[key] = len(result)
|
||||
result.append(dict(item))
|
||||
return result
|
||||
|
||||
|
||||
def _merge_arrays(base, override):
|
||||
base_arr = base if isinstance(base, list) else []
|
||||
override_arr = override if isinstance(override, list) else []
|
||||
keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
|
||||
if keyed_field:
|
||||
return _merge_by_key(base_arr, override_arr, keyed_field)
|
||||
return base_arr + override_arr
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
result = dict(base)
|
||||
for key, over_val in override.items():
|
||||
if key in result:
|
||||
result[key] = deep_merge(result[key], over_val)
|
||||
else:
|
||||
result[key] = over_val
|
||||
return result
|
||||
if isinstance(base, list) and isinstance(override, list):
|
||||
return _merge_arrays(base, override)
|
||||
return override
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve BMad central config using four-layer TOML merge.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root", "-p", required=True,
|
||||
help="Absolute path to the project root (contains _bmad/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key", "-k", action="append", default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
bmad_dir = project_root / "_bmad"
|
||||
|
||||
base_team = load_toml(bmad_dir / "config.toml", required=True)
|
||||
base_user = load_toml(bmad_dir / "config.user.toml")
|
||||
custom_team = load_toml(bmad_dir / "custom" / "config.toml")
|
||||
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml")
|
||||
|
||||
merged = deep_merge(base_team, base_user)
|
||||
merged = deep_merge(merged, custom_team)
|
||||
merged = deep_merge(merged, custom_user)
|
||||
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
else:
|
||||
output = merged
|
||||
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolve customization for a BMad skill using three-layer TOML merge.
|
||||
|
||||
Reads customization from three layers (highest priority first):
|
||||
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
|
||||
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
|
||||
3. {skill-root}/customize.toml (skill defaults)
|
||||
|
||||
Skill name is derived from the basename of the skill directory.
|
||||
|
||||
Outputs merged JSON to stdout. Errors go to stderr.
|
||||
|
||||
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`,
|
||||
no virtualenv — plain `python3` is sufficient.
|
||||
|
||||
python3 resolve_customization.py --skill /abs/path/to/skill-dir
|
||||
python3 resolve_customization.py --skill ... --key agent
|
||||
python3 resolve_customization.py --skill ... --key agent.menu
|
||||
|
||||
Merge rules (purely structural — no field-name special-casing):
|
||||
- Scalars (string, int, bool, float): override wins
|
||||
- Tables: deep merge (recursively apply these rules)
|
||||
- Arrays of tables where every item shares the *same* identifier
|
||||
field (every item has `code`, or every item has `id`):
|
||||
merge by that key (matching keys replace, new keys append)
|
||||
- All other arrays — including arrays where only some items have
|
||||
`code` or `id`, or where items mix the two keys:
|
||||
append (base items followed by override items)
|
||||
|
||||
No removal mechanism — overrides cannot delete base items. To suppress
|
||||
a default, fork the skill or override the item by code with a no-op
|
||||
description/prompt.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
sys.stderr.write(
|
||||
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n"
|
||||
"Install a newer Python or run the resolution manually per the\n"
|
||||
"fallback instructions in the skill's SKILL.md.\n"
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
_KEYED_MERGE_FIELDS = ("code", "id")
|
||||
|
||||
|
||||
def find_project_root(start: Path):
|
||||
current = start.resolve()
|
||||
while True:
|
||||
if (current / "_bmad").exists() or (current / ".git").exists():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def load_toml(file_path: Path, required: bool = False) -> dict:
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
sys.stderr.write(f"error: required customization file not found: {file_path}\n")
|
||||
sys.exit(1)
|
||||
return {}
|
||||
try:
|
||||
with file_path.open("rb") as f:
|
||||
parsed = tomllib.load(f)
|
||||
if not isinstance(parsed, dict):
|
||||
if required:
|
||||
sys.stderr.write(f"error: {file_path} did not parse to a table\n")
|
||||
sys.exit(1)
|
||||
return {}
|
||||
return parsed
|
||||
except tomllib.TOMLDecodeError as error:
|
||||
level = "error" if required else "warning"
|
||||
sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n")
|
||||
if required:
|
||||
sys.exit(1)
|
||||
return {}
|
||||
except OSError as error:
|
||||
level = "error" if required else "warning"
|
||||
sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n")
|
||||
if required:
|
||||
sys.exit(1)
|
||||
return {}
|
||||
|
||||
|
||||
def _detect_keyed_merge_field(items):
|
||||
"""Return 'code' or 'id' if every table item carries that *same* field.
|
||||
|
||||
All items must share the same identifier (all `code`, or all `id`).
|
||||
Mixed arrays — where some items use `code` and others use `id` —
|
||||
return None and fall through to append semantics. This is intentional:
|
||||
mixing identifier keys within one array is a schema smell, and
|
||||
append-fallback is safer than guessing which key should merge.
|
||||
"""
|
||||
if not items or not all(isinstance(item, dict) for item in items):
|
||||
return None
|
||||
for candidate in _KEYED_MERGE_FIELDS:
|
||||
if all(item.get(candidate) is not None for item in items):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _merge_by_key(base, override, key_name):
|
||||
result = []
|
||||
index_by_key = {}
|
||||
|
||||
for item in base:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get(key_name) is not None:
|
||||
index_by_key[item[key_name]] = len(result)
|
||||
result.append(dict(item))
|
||||
|
||||
for item in override:
|
||||
if not isinstance(item, dict):
|
||||
result.append(item)
|
||||
continue
|
||||
key = item.get(key_name)
|
||||
if key is not None and key in index_by_key:
|
||||
result[index_by_key[key]] = dict(item)
|
||||
else:
|
||||
if key is not None:
|
||||
index_by_key[key] = len(result)
|
||||
result.append(dict(item))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _merge_arrays(base, override):
|
||||
"""Shape-aware array merge. Base + override combined tables may opt into
|
||||
keyed merge if every item has `code` or `id`. Otherwise: append."""
|
||||
base_arr = base if isinstance(base, list) else []
|
||||
override_arr = override if isinstance(override, list) else []
|
||||
keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
|
||||
if keyed_field:
|
||||
return _merge_by_key(base_arr, override_arr, keyed_field)
|
||||
return base_arr + override_arr
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
"""Recursively merge override into base using structural rules.
|
||||
- Table + table: deep merge
|
||||
- Array + array: shape-aware (keyed merge if all items have code/id, else append)
|
||||
- Anything else: override wins
|
||||
"""
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
result = dict(base)
|
||||
for key, over_val in override.items():
|
||||
if key in result:
|
||||
result[key] = deep_merge(result[key], over_val)
|
||||
else:
|
||||
result[key] = over_val
|
||||
return result
|
||||
if isinstance(base, list) and isinstance(override, list):
|
||||
return _merge_arrays(base, override)
|
||||
return override
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve customization for a BMad skill using three-layer TOML merge.",
|
||||
add_help=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill", "-s", required=True,
|
||||
help="Absolute path to the skill directory (must contain customize.toml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key", "-k", action="append", default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
skill_dir = Path(args.skill).resolve()
|
||||
skill_name = skill_dir.name
|
||||
defaults_path = skill_dir / "customize.toml"
|
||||
|
||||
defaults = load_toml(defaults_path, required=True)
|
||||
|
||||
# Prefer the project that contains this skill. Only fall back to cwd if
|
||||
# the skill isn't inside a recognizable project tree (unusual but possible
|
||||
# for standalone skills invoked directly). Using cwd first is unsafe when
|
||||
# an ancestor of cwd happens to have a stray _bmad/ from another project.
|
||||
project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
|
||||
|
||||
team = {}
|
||||
user = {}
|
||||
if project_root:
|
||||
custom_dir = project_root / "_bmad" / "custom"
|
||||
team = load_toml(custom_dir / f"{skill_name}.toml")
|
||||
user = load_toml(custom_dir / f"{skill_name}.user.toml")
|
||||
|
||||
merged = deep_merge(defaults, team)
|
||||
merged = deep_merge(merged, user)
|
||||
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
else:
|
||||
output = merged
|
||||
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
# --- builder ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /repo
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/db ./packages/db
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm --filter api build
|
||||
|
||||
# --- runtime ---
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY --from=builder /repo/pnpm-workspace.yaml /repo/package.json /repo/pnpm-lock.yaml ./
|
||||
COPY --from=builder /repo/apps/api/package.json ./apps/api/
|
||||
COPY --from=builder /repo/packages/db ./packages/db
|
||||
COPY --from=builder /repo/packages/shared ./packages/shared
|
||||
COPY --from=builder /repo/apps/api/dist ./apps/api/dist
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /app/apps/api
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@cockpit/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./router": "./src/routers/_app.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=../../.env src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cockpit/db": "workspace:*",
|
||||
"@cockpit/shared": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@trpc/server": "^11.0.0-rc.660",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"fastify": "^5.2.0",
|
||||
"openid-client": "^6.1.7",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Fastify-Decorator: macht `req.cockpitUser` verfügbar, wenn die Session gültig ist.
|
||||
// Die Typaugmentation lebt in `../types.ts`, damit sie auch in tRPC-Imports
|
||||
// gezogen wird (web → api Type-Only-Import).
|
||||
|
||||
import '../types.js';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { getSessionWithUser } from './session.js';
|
||||
|
||||
export async function registerAuthMiddleware(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
app.decorateRequest('cockpitUser', null);
|
||||
app.decorateRequest('cockpitSessionId', null);
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (!sessionId) return;
|
||||
const found = await getSessionWithUser(sessionId);
|
||||
if (found) {
|
||||
req.cockpitUser = found.user;
|
||||
req.cockpitSessionId = sessionId;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Authentik OIDC via openid-client v6. Authorization Code + PKCE.
|
||||
|
||||
import * as oidc from 'openid-client';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
let cachedConfig: oidc.Configuration | undefined;
|
||||
|
||||
export async function getOidcConfig(): Promise<oidc.Configuration> {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
const env = loadEnv();
|
||||
cachedConfig = await oidc.discovery(
|
||||
new URL(env.AUTHENTIK_ISSUER),
|
||||
env.AUTHENTIK_CLIENT_ID,
|
||||
env.AUTHENTIK_CLIENT_SECRET,
|
||||
);
|
||||
logger.info({ issuer: env.AUTHENTIK_ISSUER }, 'oidc discovery completed');
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export interface PendingFlow {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export async function buildAuthorizationUrl(returnTo: string): Promise<{
|
||||
url: URL;
|
||||
pending: PendingFlow;
|
||||
}> {
|
||||
const config = await getOidcConfig();
|
||||
const env = loadEnv();
|
||||
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const nonce = oidc.randomNonce();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: env.AUTHENTIK_REDIRECT_URI,
|
||||
scope: 'openid profile email groups',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
pending: { state, nonce, codeVerifier, returnTo },
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcUserInfo {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export async function completeAuthorization(
|
||||
callbackUrl: URL,
|
||||
pending: PendingFlow,
|
||||
): Promise<OidcUserInfo> {
|
||||
const config = await getOidcConfig();
|
||||
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims) {
|
||||
throw new Error('OIDC-Token enthält keine Claims.');
|
||||
}
|
||||
|
||||
const sub = String(claims.sub ?? '');
|
||||
const email = String(claims.email ?? '');
|
||||
const name = String(claims.name ?? claims.preferred_username ?? email);
|
||||
const rawGroups = claims.groups;
|
||||
const groups: string[] = Array.isArray(rawGroups)
|
||||
? rawGroups.filter((g): g is string => typeof g === 'string')
|
||||
: [];
|
||||
|
||||
if (!sub || !email) {
|
||||
throw new Error('OIDC-Claims unvollständig (sub oder email fehlt).');
|
||||
}
|
||||
|
||||
return { sub, email, name, groups };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { buildAuthorizationUrl, completeAuthorization, type PendingFlow } from './oidc.js';
|
||||
import { createSession, deleteSession } from './session.js';
|
||||
import { upsertUserFromOidc } from './user.js';
|
||||
|
||||
const PENDING_COOKIE = 'cockpit_oidc_pending';
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, sessionId: string): void {
|
||||
const env = loadEnv();
|
||||
const maxAge = env.SESSION_TTL_HOURS * 60 * 60;
|
||||
reply.setCookie(env.SESSION_COOKIE_NAME, sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
signed: false,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookie(reply: FastifyReply): void {
|
||||
const env = loadEnv();
|
||||
reply.clearCookie(env.SESSION_COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
function setPendingCookie(reply: FastifyReply, pending: PendingFlow): void {
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: loadEnv().NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 Minuten für den Round-Trip
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingCookie(req: FastifyRequest): PendingFlow | null {
|
||||
const raw = req.cookies[PENDING_COOKIE];
|
||||
if (!raw) return null;
|
||||
const unsigned = req.unsignCookie(raw);
|
||||
if (!unsigned.valid || !unsigned.value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(unsigned.value) as PendingFlow;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
// Startet den OIDC-Flow.
|
||||
app.get('/api/auth/login', async (req, reply) => {
|
||||
const returnTo =
|
||||
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
|
||||
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
|
||||
: '/puls';
|
||||
|
||||
const { url, pending } = await buildAuthorizationUrl(returnTo);
|
||||
setPendingCookie(reply, pending);
|
||||
return reply.redirect(url.toString(), 302);
|
||||
});
|
||||
|
||||
// OIDC-Callback. Tauscht den Code gegen Tokens und legt eine Session an.
|
||||
app.get('/api/auth/callback', async (req, reply) => {
|
||||
const pending = readPendingCookie(req);
|
||||
if (!pending) {
|
||||
logger.warn('callback ohne pending-cookie');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=session_expired`, 302);
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(env.AUTHENTIK_REDIRECT_URI);
|
||||
for (const [k, v] of Object.entries(req.query as Record<string, string>)) {
|
||||
callbackUrl.searchParams.set(k, v);
|
||||
}
|
||||
|
||||
const oidcUser = await completeAuthorization(callbackUrl, pending);
|
||||
const userRow = await upsertUserFromOidc(oidcUser);
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
setSessionCookie(reply, newSession.id);
|
||||
reply.clearCookie(PENDING_COOKIE, { path: '/' });
|
||||
|
||||
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'oidc callback failed');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=oidc_failed`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (sessionId) {
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Dev-Auto-Login: legt sofort eine Session für den Admin-Dejan an, sodass
|
||||
// die App ohne Authentik durchnavigierbar ist. Authentik kommt in der späteren
|
||||
// Roadmap. Nur aktiv bei NODE_ENV=development.
|
||||
if (env.NODE_ENV === 'development') {
|
||||
const handler = async (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => {
|
||||
const { getDb } = await import('../db.js');
|
||||
const { user } = await import('@cockpit/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const { db } = getDb();
|
||||
|
||||
const found = (await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1))[0];
|
||||
const userRow = found
|
||||
? found
|
||||
: await upsertUserFromOidc({
|
||||
sub: 'local-dejan',
|
||||
email: 'dejan@tool-ctrl.de',
|
||||
name: 'Dejan Cosic',
|
||||
groups: ['entwicklung'],
|
||||
});
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
setSessionCookie(reply, newSession.id);
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
|
||||
};
|
||||
app.get('/api/auth/dev-login', handler);
|
||||
app.post('/api/auth/dev-login', handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Session-Store in Postgres. Browser sieht nur `session_id` als HttpOnly-Cookie.
|
||||
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { session, user, type Session, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import { loadEnv } from '../env.js';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<Session> {
|
||||
const env = loadEnv();
|
||||
const { db } = getDb();
|
||||
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000);
|
||||
|
||||
const [created] = await db
|
||||
.insert(session)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
expiresAt,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Session konnte nicht angelegt werden.');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function getSessionWithUser(
|
||||
sessionId: string,
|
||||
): Promise<{ session: Session; user: User } | null> {
|
||||
const { db } = getDb();
|
||||
const rows = await db
|
||||
.select({ session, user })
|
||||
.from(session)
|
||||
.innerJoin(user, eq(session.userId, user.id))
|
||||
.where(eq(session.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.session.expiresAt.getTime() <= Date.now()) {
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const { db } = getDb();
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
}
|
||||
|
||||
export async function pruneExpiredSessions(): Promise<number> {
|
||||
const { db } = getDb();
|
||||
const result = await db.delete(session).where(lt(session.expiresAt, new Date()));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { user, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import type { OidcUserInfo } from './oidc.js';
|
||||
|
||||
// MVP: Group-Claim → default_area_id wird in Story 1.x mit echter Area-Tabelle
|
||||
// gemappt. Aktuell speichern wir den User mit `default_area_id = null` und
|
||||
// pflegen den Bereich später manuell. (OQ-1.)
|
||||
export async function upsertUserFromOidc(info: OidcUserInfo): Promise<User> {
|
||||
const { db } = getDb();
|
||||
|
||||
const existing = await db.select().from(user).where(eq(user.authentikSub, info.sub)).limit(1);
|
||||
if (existing[0]) {
|
||||
const [updated] = await db
|
||||
.update(user)
|
||||
.set({
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, existing[0].id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('User-Update fehlgeschlagen.');
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
authentikSub: info.sub,
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('User-Anlage fehlgeschlagen.');
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { getDb } from '../db.js';
|
||||
import { publishYesterdaysEntries } from '../routers/day-entry.js';
|
||||
import { previousWerktag, todayIso } from '../lib/werktag.js';
|
||||
|
||||
function authorize(req: FastifyRequest): void {
|
||||
const env = loadEnv();
|
||||
const header = req.headers.authorization ?? '';
|
||||
const expected = `Bearer ${env.CRON_BEARER_TOKEN}`;
|
||||
if (header !== expected) {
|
||||
throw new Error('Unauthorized cron call.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerCronRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/internal/cron/publish-day-entries', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
logger.warn({ err }, 'unauthorized cron call');
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
const { db } = getDb();
|
||||
const today = todayIso();
|
||||
const yesterday = previousWerktag(today);
|
||||
const updated = await publishYesterdaysEntries({ db }, yesterday);
|
||||
logger.info({ updated, date: yesterday }, 'published yesterday entries');
|
||||
return { ok: true, updated, date: yesterday };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/recompute-streaks', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
// Streaks werden lazy in `user.me` berechnet. Dieser Endpoint ist Reserve
|
||||
// für künftige denormalisierte Caches (NFR-1 Performance, sobald viele User).
|
||||
return { ok: true, note: 'streaks are computed lazily' };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/recompute-pflicht-minimum-5', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
const { db } = getDb();
|
||||
const { recomputePflichtMinimumStatusActual } = await import('../lib/pflicht-minimum.js');
|
||||
const res = await recomputePflichtMinimumStatusActual(db);
|
||||
logger.info(res, 'pflicht-minimum status_aktuell recomputed');
|
||||
return { ok: true, ...res };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/pg-dump-backup', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
// pg_dump läuft als System-Cron *im Postgres-Container* (siehe
|
||||
// docs/operations.md § Backups). Dieser Endpoint ist der Audit-Trigger:
|
||||
// wenn er gerufen wird, ist der Backup-Cron lebendig und der Logeintrag
|
||||
// landet im API-Log (zentrale Beobachtbarkeit).
|
||||
logger.info('pg-dump-backup heartbeat received');
|
||||
return {
|
||||
ok: true,
|
||||
note: 'pg_dump runs in postgres container; see docs/operations.md',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createDbClient, type Db } from '@cockpit/db';
|
||||
import type pg from 'pg';
|
||||
import { loadEnv } from './env.js';
|
||||
|
||||
let cached: { db: Db; pool: pg.Pool } | undefined;
|
||||
|
||||
export function getDb(): { db: Db; pool: pg.Pool } {
|
||||
if (cached) return cached;
|
||||
const env = loadEnv();
|
||||
cached = createDbClient({ connectionString: env.DATABASE_URL });
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
PORT: z.coerce.number().int().default(3000),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
AUTHENTIK_ISSUER: z.string().url(),
|
||||
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
||||
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
|
||||
AUTHENTIK_REDIRECT_URI: z.string().url(),
|
||||
|
||||
SESSION_COOKIE_NAME: z.string().default('cockpit_session'),
|
||||
SESSION_TTL_HOURS: z.coerce.number().int().min(1).default(24),
|
||||
SESSION_COOKIE_SECRET: z.string().min(32),
|
||||
|
||||
CRON_BEARER_TOKEN: z.string().min(8),
|
||||
|
||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:5173'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
let cached: Env | undefined;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
if (cached) return cached;
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Ungültige Umgebung:\n${issues}`);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Ampel-Aggregations-Algorithmus (FR-1a).
|
||||
|
||||
export type Ampel = 'green' | 'amber' | 'red';
|
||||
export type AreaAmpel = Ampel | 'grey';
|
||||
|
||||
export interface ProjectStatusLike {
|
||||
statusCache: Ampel | null;
|
||||
pflichtMinimumState: Record<string, boolean>;
|
||||
archiviert: boolean;
|
||||
}
|
||||
|
||||
export function aggregateAreaAmpel(projects: ProjectStatusLike[]): {
|
||||
ampel: AreaAmpel;
|
||||
counts: { green: number; amber: number; red: number; grey: number };
|
||||
} {
|
||||
const active = projects.filter((p) => !p.archiviert);
|
||||
if (active.length === 0)
|
||||
return { ampel: 'grey', counts: { green: 0, amber: 0, red: 0, grey: 0 } };
|
||||
|
||||
let red = 0;
|
||||
let amber = 0;
|
||||
let green = 0;
|
||||
let grey = 0;
|
||||
for (const p of active) {
|
||||
// Pflicht-Minimum-Verstoss hebt mindestens auf gelb.
|
||||
const hasMissingPflicht = Object.entries(p.pflichtMinimumState).some(([_, v]) => v === false);
|
||||
let s: Ampel | 'grey' = p.statusCache ?? 'grey';
|
||||
if (s === 'green' && hasMissingPflicht) s = 'amber';
|
||||
if (s === 'red') red++;
|
||||
else if (s === 'amber') amber++;
|
||||
else if (s === 'green') green++;
|
||||
else grey++;
|
||||
}
|
||||
|
||||
if (red > 0) return { ampel: 'red', counts: { green, amber, red, grey } };
|
||||
if (amber > 0) return { ampel: 'amber', counts: { green, amber, red, grey } };
|
||||
if (green > 0) return { ampel: 'green', counts: { green, amber, red, grey } };
|
||||
return { ampel: 'grey', counts: { green, amber, red, grey } };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
base: { service: 'cockpit-api' },
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Pflicht-Minimum-Berechnung (Leitfaden §9).
|
||||
// Punkte 1–4 + 6 werden manuell in der App gepflegt; Punkt 5 (Status-Ampel aktuell)
|
||||
// wird hier zeitabhängig berechnet und im Cron auf alle Projekte aktualisiert.
|
||||
|
||||
import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm';
|
||||
import { project, dayEntry } from '@cockpit/db';
|
||||
import { werktageBetween, todayIso } from './werktag.js';
|
||||
|
||||
const MAX_AGE_WERKTAGE = 7;
|
||||
|
||||
export async function recomputePflichtMinimumStatusActual(
|
||||
db: import('@cockpit/db').Db,
|
||||
): Promise<{ updated: number }> {
|
||||
const projects = await db.select().from(project).where(eq(project.archiviert, false));
|
||||
const today = todayIso();
|
||||
let updated = 0;
|
||||
for (const p of projects) {
|
||||
// Letzte Reflexion mit Projektbezug? (Vereinfachung: letzter day_entry mit Ampel,
|
||||
// dessen Update das Projekt-Status-Cache gesetzt hat = `status_cache` selbst.)
|
||||
const lastEntry = (
|
||||
await db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(isNotNull(dayEntry.reflectedAt)))
|
||||
.orderBy(desc(dayEntry.reflectedAt))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false;
|
||||
|
||||
const newState = { ...p.pflichtMinimumState, status_aktuell: actual };
|
||||
if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) {
|
||||
await db.update(project).set({ pflichtMinimumState: newState }).where(eq(project.id, p.id));
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
// linter
|
||||
void isNull;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatIsoDate, isWerktag, nextWerktag, previousWerktag, werktageBetween } from './werktag';
|
||||
|
||||
describe('werktag', () => {
|
||||
it('formatIsoDate liefert YYYY-MM-DD in Berlin TZ', () => {
|
||||
const d = new Date(Date.UTC(2026, 4, 25, 8, 30));
|
||||
expect(formatIsoDate(d)).toBe('2026-05-25');
|
||||
});
|
||||
|
||||
it('isWerktag erkennt Mo-Fr', () => {
|
||||
expect(isWerktag('2026-05-25')).toBe(true); // Mo
|
||||
expect(isWerktag('2026-05-29')).toBe(true); // Fr
|
||||
expect(isWerktag('2026-05-30')).toBe(false); // Sa
|
||||
expect(isWerktag('2026-05-31')).toBe(false); // So
|
||||
});
|
||||
|
||||
it('nextWerktag springt über Wochenende', () => {
|
||||
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
|
||||
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
|
||||
});
|
||||
|
||||
it('previousWerktag springt über Wochenende', () => {
|
||||
expect(previousWerktag('2026-06-01')).toBe('2026-05-29'); // Mo → Fr
|
||||
});
|
||||
|
||||
it('werktageBetween zählt korrekt', () => {
|
||||
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
|
||||
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
|
||||
// (Frontend bekommt fertige Felder).
|
||||
|
||||
const TIMEZONE = 'Europe/Berlin';
|
||||
|
||||
/**
|
||||
* Gibt das aktuelle Datum (oder `now`) als `YYYY-MM-DD` in Europe/Berlin zurück.
|
||||
*/
|
||||
export function todayIso(now: Date = new Date()): string {
|
||||
return formatIsoDate(now);
|
||||
}
|
||||
|
||||
export function formatIsoDate(d: Date): string {
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return fmt.format(d); // en-CA liefert ISO YYYY-MM-DD
|
||||
}
|
||||
|
||||
export function parseIsoDate(s: string): Date {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
throw new Error(`Ungültiges ISO-Datum: ${s}`);
|
||||
}
|
||||
const [y, m, d] = s.split('-').map(Number) as [number, number, number];
|
||||
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); // 12:00 UTC vermeidet TZ-Drift
|
||||
}
|
||||
|
||||
/**
|
||||
* Mo-Fr = Werktag. Feiertage Phase 2.
|
||||
*/
|
||||
export function isWerktag(s: string): boolean {
|
||||
const d = parseIsoDate(s);
|
||||
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
|
||||
return wd !== 'Sat' && wd !== 'Sun';
|
||||
}
|
||||
|
||||
export function nextWerktag(s: string): string {
|
||||
let cur = parseIsoDate(s);
|
||||
do {
|
||||
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
|
||||
} while (!isWerktag(formatIsoDate(cur)));
|
||||
return formatIsoDate(cur);
|
||||
}
|
||||
|
||||
export function previousWerktag(s: string): string {
|
||||
let cur = parseIsoDate(s);
|
||||
do {
|
||||
cur = new Date(cur.getTime() - 24 * 60 * 60 * 1000);
|
||||
} while (!isWerktag(formatIsoDate(cur)));
|
||||
return formatIsoDate(cur);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt Werktage zwischen zwei ISO-Daten (inkl. start, exkl. end).
|
||||
*/
|
||||
export function werktageBetween(startIso: string, endIso: string): number {
|
||||
let cur = parseIsoDate(startIso);
|
||||
const end = parseIsoDate(endIso);
|
||||
let count = 0;
|
||||
while (cur.getTime() < end.getTime()) {
|
||||
if (isWerktag(formatIsoDate(cur))) count++;
|
||||
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Root tRPC-Router. Pro Story kommen weitere Sub-Router dazu.
|
||||
|
||||
import { router, publicProcedure } from '../trpc.js';
|
||||
import { dayEntryRouter } from './day-entry.js';
|
||||
import { userRouter } from './user.js';
|
||||
import { pulsRouter } from './puls.js';
|
||||
import { projectRouter } from './project.js';
|
||||
import { dealRouter, gtmRouter } from './deal.js';
|
||||
import { campaignRouter } from './campaign.js';
|
||||
import { areaRouter } from './area.js';
|
||||
import { blockerRouter } from './blocker.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: publicProcedure.query(() => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
user: userRouter,
|
||||
area: areaRouter,
|
||||
project: projectRouter,
|
||||
deal: dealRouter,
|
||||
gtm: gtmRouter,
|
||||
campaign: campaignRouter,
|
||||
blocker: blockerRouter,
|
||||
dayEntry: dayEntryRouter,
|
||||
puls: pulsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { area } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
export const areaRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(area).orderBy(area.order);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq, isNull, desc } from 'drizzle-orm';
|
||||
import { blocker, project, user } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
export const blockerRouter = router({
|
||||
listOpen: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db
|
||||
.select({
|
||||
blocker: blocker,
|
||||
opener: user,
|
||||
})
|
||||
.from(blocker)
|
||||
.leftJoin(user, eq(user.id, blocker.openedBy))
|
||||
.where(isNull(blocker.resolvedAt))
|
||||
.orderBy(desc(blocker.openedAt));
|
||||
}),
|
||||
|
||||
byProject: protectedProcedure
|
||||
.input(z.object({ projectId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(blocker)
|
||||
.where(eq(blocker.projectId, input.projectId))
|
||||
.orderBy(desc(blocker.openedAt));
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
areaId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
ownerUserId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [created] = await ctx.db
|
||||
.insert(blocker)
|
||||
.values({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
areaId: input.areaId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
openedBy: ctx.user.id,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
resolve: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
blockerId: z.string().uuid(),
|
||||
resolutionText: z.string().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const b = (
|
||||
await ctx.db.select().from(blocker).where(eq(blocker.id, input.blockerId)).limit(1)
|
||||
)[0];
|
||||
if (!b) throw new TRPCError({ code: 'NOT_FOUND', message: 'Blocker nicht gefunden.' });
|
||||
|
||||
// Authority-Check: bei projektgebundenen Blockern nur P1; sonst Ersteller oder Verantwortlicher.
|
||||
let allowed = false;
|
||||
if (b.projectId) {
|
||||
const p = (
|
||||
await ctx.db.select().from(project).where(eq(project.id, b.projectId)).limit(1)
|
||||
)[0];
|
||||
if (p?.verantwortlicheUserId === ctx.user.id) allowed = true;
|
||||
} else {
|
||||
if (b.openedBy === ctx.user.id || b.ownerUserId === ctx.user.id) allowed = true;
|
||||
}
|
||||
if (!allowed) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message:
|
||||
'Nur die/der Projektverantwortliche darf diesen Blocker auflösen. (Bei projektlosen Blockern: Ersteller oder Verantwortliche/r.)',
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(blocker)
|
||||
.set({
|
||||
resolvedBy: ctx.user.id,
|
||||
resolvedAt: new Date(),
|
||||
resolutionText: input.resolutionText,
|
||||
})
|
||||
.where(eq(blocker.id, input.blockerId))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { campaign, leadHandover, deal, area } from '@cockpit/db';
|
||||
import { CampaignSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const MARKETING_AREA_SLUG = 'marketing';
|
||||
|
||||
export const campaignRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(campaign).orderBy(desc(campaign.createdAt));
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CampaignSchemas.createCampaignInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const a = (
|
||||
await ctx.db.select().from(area).where(eq(area.slug, MARKETING_AREA_SLUG)).limit(1)
|
||||
)[0];
|
||||
if (!a) throw new Error('Marketing-Area fehlt — bitte seed laufen lassen.');
|
||||
const [created] = await ctx.db
|
||||
.insert(campaign)
|
||||
.values({
|
||||
areaId: a.id,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
reach: input.reach,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
funnel: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Lead-Funnel: simple Aggregation der bekannten Quellen.
|
||||
const campaigns = await ctx.db.select().from(campaign);
|
||||
const reichweite = campaigns.reduce((acc, c) => acc + c.reach, 0);
|
||||
const handovers = await ctx.db.select().from(leadHandover);
|
||||
const leads = handovers.length;
|
||||
const deals = await ctx.db.select().from(deal);
|
||||
const mql = deals.filter((d) => ['erstgespraech', 'demo', 'angebot'].includes(d.stage)).length;
|
||||
const uebergabe = handovers.length;
|
||||
// Frontend kann die Stage-Übergänge daraus rendern.
|
||||
return {
|
||||
reichweite,
|
||||
website: Math.round(reichweite * 0.2), // Placeholder bis ein Tracking-Feed existiert.
|
||||
leads,
|
||||
mql,
|
||||
uebergabe,
|
||||
};
|
||||
}),
|
||||
|
||||
crosslink: protectedProcedure.query(async ({ ctx }) => {
|
||||
const handovers = await ctx.db.select().from(leadHandover);
|
||||
const dealsFromHandovers = handovers.filter((h) => h.dealId !== null).length;
|
||||
// Won-Volumen aus Handover-bezogenen Deals dieses Quartals.
|
||||
const dealRows = await ctx.db.select().from(deal);
|
||||
const wonValue = dealRows
|
||||
.filter((d) => d.wonAt)
|
||||
.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
|
||||
return {
|
||||
uebergebenSeitTagen: 30,
|
||||
leadsUebergeben: handovers.length,
|
||||
dealsErzeugt: dealsFromHandovers,
|
||||
gewonneneEurQuartal: wonValue,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, isNull, isNotNull, lt, asc, inArray } from 'drizzle-orm';
|
||||
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso } from '../lib/werktag.js';
|
||||
|
||||
export const dayEntryRouter = router({
|
||||
/**
|
||||
* Heutige ToDos aller aktiven Mitarbeiter, gruppiert pro User.
|
||||
* Pro PRD-Änderung 2026-05-25: Heutige ToDos sind teamweit sichtbar
|
||||
* (ersetzt die ältere private-vor-Publikation-Regel).
|
||||
*/
|
||||
todayTeam: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, date));
|
||||
if (entries.length === 0) return [] as Array<{ userId: string; entryStatus: string; todos: Todo[] }>;
|
||||
const ids = entries.map((e) => e.id);
|
||||
const todos = await ctx.db
|
||||
.select()
|
||||
.from(todo)
|
||||
.where(inArray(todo.dayEntryId, ids))
|
||||
.orderBy(asc(todo.order));
|
||||
const todosByEntry = new Map<string, Todo[]>();
|
||||
for (const t of todos) {
|
||||
const arr = todosByEntry.get(t.dayEntryId) ?? [];
|
||||
arr.push(t);
|
||||
todosByEntry.set(t.dayEntryId, arr);
|
||||
}
|
||||
return entries.map((e) => ({
|
||||
userId: e.userId,
|
||||
entryStatus: e.status,
|
||||
todos: todosByEntry.get(e.id) ?? [],
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Offene ToDos der vergangenen Tage, die noch nicht übertragen wurden.
|
||||
* Werden auf der Heute-Seite vor dem Einstempeln eingeblendet (siehe FR-22 Erweiterung).
|
||||
*/
|
||||
pendingCarryOver: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db
|
||||
.select({
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
bezugKind: todo.bezugKind,
|
||||
bezugId: todo.bezugId,
|
||||
fromDate: todo.date,
|
||||
})
|
||||
.from(todo)
|
||||
.where(
|
||||
and(
|
||||
eq(todo.userId, ctx.user.id),
|
||||
eq(todo.status, 'open'),
|
||||
eq(todo.carriedOver, false),
|
||||
lt(todo.date, todayIso()),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(todo.date), asc(todo.order))
|
||||
.limit(20);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Heute-Status der eigenen Person.
|
||||
*/
|
||||
today: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
|
||||
.limit(1);
|
||||
const entry = rows[0] ?? null;
|
||||
if (!entry) {
|
||||
return { date, entry: null, todos: [] as Todo[] };
|
||||
}
|
||||
const todos = await ctx.db
|
||||
.select()
|
||||
.from(todo)
|
||||
.where(eq(todo.dayEntryId, entry.id))
|
||||
.orderBy(todo.order);
|
||||
return { date, entry, todos };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Morgen-Einstempeln + ToDo-Liste setzen.
|
||||
*/
|
||||
checkIn: protectedProcedure
|
||||
.input(DayEntrySchemas.checkInInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const date = input.date ?? todayIso();
|
||||
|
||||
// Verhindert Doppel-Einstempeln. Wenn bereits Einträge existieren, Fehler.
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Du bist heute schon locked in.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(dayEntry)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
date,
|
||||
status: 'begun',
|
||||
checkedInAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('DayEntry konnte nicht angelegt werden.');
|
||||
|
||||
const todoRows = await tx
|
||||
.insert(todo)
|
||||
.values(
|
||||
input.todos.map((t, idx) => ({
|
||||
dayEntryId: created.id,
|
||||
userId: ctx.user.id,
|
||||
date,
|
||||
title: t.title,
|
||||
order: idx,
|
||||
bezugKind: t.bezugKind ?? null,
|
||||
bezugId: t.bezugId ?? null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Alle alten offenen ToDos der Person als "übertragen" markieren.
|
||||
// Egal, ob sie in der heutigen Liste landeten oder nicht — der User hat
|
||||
// beim Einstempeln über sie entschieden.
|
||||
const stale = await tx
|
||||
.select({ id: todo.id })
|
||||
.from(todo)
|
||||
.where(
|
||||
and(
|
||||
eq(todo.userId, ctx.user.id),
|
||||
eq(todo.status, 'open'),
|
||||
eq(todo.carriedOver, false),
|
||||
lt(todo.date, date),
|
||||
),
|
||||
);
|
||||
if (stale.length > 0) {
|
||||
await tx
|
||||
.update(todo)
|
||||
.set({ carriedOver: true })
|
||||
.where(
|
||||
inArray(
|
||||
todo.id,
|
||||
stale.map((s) => s.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { entry: created, todos: todoRows, carriedOver: stale.length };
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* ToDos im Verlauf des Tages ändern (vor Reflexion).
|
||||
*/
|
||||
updateTodos: protectedProcedure
|
||||
.input(DayEntrySchemas.todoMutationInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
|
||||
.limit(1);
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
|
||||
}
|
||||
if (entry.status === 'full') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Du hast schon reflektiert. ToDos sind eingefroren.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
await tx.delete(todo).where(eq(todo.dayEntryId, entry.id));
|
||||
if (input.todos.length === 0) return { todos: [] as Todo[] };
|
||||
const newTodos = await tx
|
||||
.insert(todo)
|
||||
.values(
|
||||
input.todos.map((t, idx) => ({
|
||||
dayEntryId: entry.id,
|
||||
userId: ctx.user.id,
|
||||
date: entry.date,
|
||||
title: t.title,
|
||||
order: idx,
|
||||
bezugKind: t.bezugKind ?? null,
|
||||
bezugId: t.bezugId ?? null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
return { todos: newTodos };
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
|
||||
*/
|
||||
submitReflection: protectedProcedure
|
||||
.input(DayEntrySchemas.submitReflectionInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
|
||||
.limit(1);
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
|
||||
}
|
||||
if (entry.status === 'full' && entry.editLockedAt && entry.editLockedAt < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Die Reflexion ist nicht mehr editierbar.',
|
||||
});
|
||||
}
|
||||
|
||||
// Pflicht-Trend bei Bezug=Projekt (Story 2.4)
|
||||
const todoRows = await ctx.db.select().from(todo).where(eq(todo.dayEntryId, entry.id));
|
||||
const hasProjectBezug = todoRows.some((t) => t.bezugKind === 'project');
|
||||
if (hasProjectBezug && !input.trend) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Bei Projektbezug ist der Trend Pflicht.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
// ToDo-Statuswechsel.
|
||||
for (const t of input.todos) {
|
||||
await tx
|
||||
.update(todo)
|
||||
.set({
|
||||
status: t.status,
|
||||
dropReason: t.dropReason ?? null,
|
||||
completedAt: t.status === 'done' ? new Date() : null,
|
||||
droppedAt: t.status === 'dropped' ? new Date() : null,
|
||||
})
|
||||
.where(and(eq(todo.id, t.id), eq(todo.dayEntryId, entry.id)));
|
||||
}
|
||||
|
||||
let blockerRecord: { id: string } | null = null;
|
||||
if (input.blocker) {
|
||||
const [b] = await tx
|
||||
.insert(blocker)
|
||||
.values({
|
||||
title: input.blocker.title,
|
||||
description: input.blocker.description ?? null,
|
||||
openedBy: ctx.user.id,
|
||||
})
|
||||
.returning({ id: blocker.id });
|
||||
blockerRecord = b ?? null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await tx
|
||||
.update(dayEntry)
|
||||
.set({
|
||||
status: 'full',
|
||||
reflectedAt: now,
|
||||
reflectionText: input.reflectionText,
|
||||
ampel: input.ampel,
|
||||
trend: input.trend ?? null,
|
||||
url: input.url ?? null,
|
||||
blockerId: blockerRecord?.id ?? entry.blockerId,
|
||||
editLockedAt: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(dayEntry.id, entry.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('Update fehlgeschlagen.');
|
||||
return { entry: updated, blocker: blockerRecord };
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Konsolidierter Vortrags-Feed: alle bereits publizierten Einträge.
|
||||
* Nutzbar in Story 3.4 (Puls-Feed).
|
||||
*/
|
||||
feed: protectedProcedure.query(async ({ ctx }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(isNotNull(dayEntry.publishedAt))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.limit(50);
|
||||
return entries;
|
||||
}),
|
||||
|
||||
/**
|
||||
* „Heute fehlt"-Liste (Anwesenheits-Übersicht).
|
||||
*/
|
||||
todayPresence: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
userId: dayEntry.userId,
|
||||
status: dayEntry.status,
|
||||
})
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, date));
|
||||
return rows;
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper für Cron-Endpoint
|
||||
export async function publishYesterdaysEntries(
|
||||
ctx: { db: import('@cockpit/db').Db },
|
||||
yesterdayIso: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const result = await ctx.db
|
||||
.update(dayEntry)
|
||||
.set({ publishedAt: now })
|
||||
.where(and(eq(dayEntry.date, yesterdayIso), isNull(dayEntry.publishedAt)));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { desc, eq, sql, sum } from 'drizzle-orm';
|
||||
import { deal, gtmStep, area } from '@cockpit/db';
|
||||
import { DealSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const SALES_AREA_SLUG = 'vertrieb';
|
||||
|
||||
async function getSalesAreaId(db: import('@cockpit/db').Db): Promise<string> {
|
||||
const a = (await db.select().from(area).where(eq(area.slug, SALES_AREA_SLUG)).limit(1))[0];
|
||||
if (!a) throw new Error('Sales-Area fehlt — bitte seed laufen lassen.');
|
||||
return a.id;
|
||||
}
|
||||
|
||||
export const dealRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(deal).orderBy(desc(deal.enteredStageAt));
|
||||
}),
|
||||
|
||||
kpis: protectedProcedure.query(async ({ ctx }) => {
|
||||
const allDeals = await ctx.db.select().from(deal);
|
||||
const open = allDeals.filter((d) => !d.wonAt);
|
||||
const won = allDeals.filter((d) => d.wonAt);
|
||||
|
||||
const weightedPipeline = open.reduce((acc, d) => acc + (d.valueEur * d.probability) / 100, 0);
|
||||
|
||||
// „Laufendes Quartal" = aktuelles Kalenderquartal (Server-Zeit).
|
||||
const now = new Date();
|
||||
const qStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
|
||||
const wonThisQuarter = won.filter((d) => d.wonAt && d.wonAt >= qStart);
|
||||
const wonValueQuarter = wonThisQuarter.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
|
||||
|
||||
return {
|
||||
weightedPipelineEur: Math.round(weightedPipeline),
|
||||
openDealCount: open.length,
|
||||
wonCountQuarter: wonThisQuarter.length,
|
||||
wonValueQuarterEur: wonValueQuarter,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(DealSchemas.createDealInput).mutation(async ({ ctx, input }) => {
|
||||
const areaId = await getSalesAreaId(ctx.db);
|
||||
const [created] = await ctx.db
|
||||
.insert(deal)
|
||||
.values({
|
||||
areaId,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
valueEur: input.valueEur,
|
||||
probability: input.probability,
|
||||
stage: input.stage,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
updateStage: protectedProcedure
|
||||
.input(DealSchemas.updateDealStageInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(deal)
|
||||
.set({
|
||||
stage: input.stage,
|
||||
enteredStageAt: new Date(),
|
||||
wonAt: input.stage === 'abschluss' ? new Date() : null,
|
||||
wonValueEur:
|
||||
input.stage === 'abschluss'
|
||||
? sql`COALESCE(${deal.valueEur} * ${deal.probability} / 100, 0)`
|
||||
: null,
|
||||
})
|
||||
.where(eq(deal.id, input.dealId))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Deal nicht gefunden.' });
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
export const gtmRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ product: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.product) {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(gtmStep)
|
||||
.where(eq(gtmStep.product, input.product))
|
||||
.orderBy(gtmStep.order);
|
||||
}
|
||||
return ctx.db.select().from(gtmStep).orderBy(gtmStep.product, gtmStep.order);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
product: z.string().min(1).max(100),
|
||||
title: z.string().min(1).max(200),
|
||||
ownerUserId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(gtmStep)
|
||||
.where(eq(gtmStep.product, input.product));
|
||||
const [created] = await ctx.db
|
||||
.insert(gtmStep)
|
||||
.values({
|
||||
product: input.product,
|
||||
title: input.title,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
order: existing.length,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
setStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum(['open', 'in_progress', 'done']),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(gtmStep)
|
||||
.set({ status: input.status })
|
||||
.where(eq(gtmStep.id, input.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
// linter
|
||||
void sum;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, desc } from 'drizzle-orm';
|
||||
import { project, milestone, area, user, type Project } from '@cockpit/db';
|
||||
import { ProjectSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const byAreaInput = z.object({
|
||||
slug: z.string().min(1),
|
||||
includeArchived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const byIdInput = z.object({ id: z.string().uuid() });
|
||||
|
||||
export const projectRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
project: project,
|
||||
area: area,
|
||||
verantwortliche: user,
|
||||
})
|
||||
.from(project)
|
||||
.innerJoin(area, eq(project.areaId, area.id))
|
||||
.leftJoin(user, eq(user.id, project.verantwortlicheUserId))
|
||||
.where(eq(project.archiviert, false));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
byAreaSlug: protectedProcedure.input(byAreaInput).query(async ({ ctx, input }) => {
|
||||
const a = (await ctx.db.select().from(area).where(eq(area.slug, input.slug)).limit(1))[0];
|
||||
if (!a) return [];
|
||||
const projects = await ctx.db
|
||||
.select()
|
||||
.from(project)
|
||||
.where(
|
||||
input.includeArchived
|
||||
? eq(project.areaId, a.id)
|
||||
: and(eq(project.areaId, a.id), eq(project.archiviert, false)),
|
||||
)
|
||||
.orderBy(desc(project.createdAt));
|
||||
return projects;
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdInput).query(async ({ ctx, input }) => {
|
||||
const rows = await ctx.db
|
||||
.select({ project: project, area: area })
|
||||
.from(project)
|
||||
.innerJoin(area, eq(area.id, project.areaId))
|
||||
.where(eq(project.id, input.id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
|
||||
const ms = await ctx.db.select().from(milestone).where(eq(milestone.projectId, row.project.id));
|
||||
return { project: row.project, area: row.area, milestones: ms };
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(ProjectSchemas.createProjectInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(project)
|
||||
.values({
|
||||
areaId: input.areaId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
verantwortlicheUserId: input.verantwortlicheUserId ?? null,
|
||||
arbeitsmodus: input.arbeitsmodus ?? null,
|
||||
outlineSteckbriefUrl: input.outlineSteckbriefUrl,
|
||||
planeProjectUrl: input.planeProjectUrl,
|
||||
risikolisteUrl: input.risikolisteUrl ?? null,
|
||||
dodText: input.dodText ?? null,
|
||||
start: input.start ?? null,
|
||||
deadline: input.deadline ?? null,
|
||||
createdBy: ctx.user.id,
|
||||
pflichtMinimumState: {
|
||||
steckbrief_freigegeben: false,
|
||||
verantwortlicher: !!input.verantwortlicheUserId,
|
||||
plane_board: true,
|
||||
meilensteine: input.milestones.length > 0,
|
||||
status_aktuell: false,
|
||||
retrospektive: false,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('Projekt konnte nicht angelegt werden.');
|
||||
|
||||
if (input.milestones.length > 0) {
|
||||
await tx.insert(milestone).values(
|
||||
input.milestones.map((m, idx) => ({
|
||||
projectId: created.id,
|
||||
title: m.title,
|
||||
due: m.due ?? null,
|
||||
order: idx,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.input(ProjectSchemas.archiveProjectInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const p = (
|
||||
await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1)
|
||||
)[0];
|
||||
if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
|
||||
if (p.verantwortlicheUserId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Nur die/der Projektverantwortliche darf das Projekt abschließen.',
|
||||
});
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(project)
|
||||
.set({
|
||||
archiviert: true,
|
||||
archiviertAm: new Date(),
|
||||
lessonsLearnedUrl: input.lessonsLearnedUrl,
|
||||
pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true },
|
||||
})
|
||||
.where(eq(project.id, input.projectId))
|
||||
.returning();
|
||||
return updated as Project;
|
||||
}),
|
||||
|
||||
setMilestoneStatus: protectedProcedure
|
||||
.input(ProjectSchemas.setMilestoneStatusInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(milestone)
|
||||
.set({ status: input.status })
|
||||
.where(eq(milestone.id, input.milestoneId))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
listArchived: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db
|
||||
.select()
|
||||
.from(project)
|
||||
.where(eq(project.archiviert, true))
|
||||
.orderBy(desc(project.archiviertAm));
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq, isNotNull, isNull, count } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, type Area, type Project } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, previousWerktag } from '../lib/werktag.js';
|
||||
import { aggregateAreaAmpel } from '../lib/ampel.js';
|
||||
|
||||
export const pulsRouter = router({
|
||||
overview: protectedProcedure.query(async ({ ctx }) => {
|
||||
const todayDate = todayIso();
|
||||
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows] =
|
||||
await Promise.all([
|
||||
ctx.db.select().from(area).orderBy(area.order),
|
||||
ctx.db.select().from(project).where(eq(project.archiviert, false)),
|
||||
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
|
||||
ctx.db
|
||||
.select({ userId: dayEntry.userId, status: dayEntry.status })
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, todayDate)),
|
||||
ctx.db.select({ c: count() }).from(user).where(eq(user.active, true)),
|
||||
ctx.db
|
||||
.select({
|
||||
id: dayEntry.id,
|
||||
userId: dayEntry.userId,
|
||||
userName: user.name,
|
||||
date: dayEntry.date,
|
||||
ampel: dayEntry.ampel,
|
||||
trend: dayEntry.trend,
|
||||
reflectionText: dayEntry.reflectionText,
|
||||
publishedAt: dayEntry.publishedAt,
|
||||
})
|
||||
.from(dayEntry)
|
||||
.innerJoin(user, eq(user.id, dayEntry.userId))
|
||||
.where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full')))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.limit(50),
|
||||
]);
|
||||
|
||||
const projectsByArea = new Map<string, Project[]>();
|
||||
for (const p of allProjects) {
|
||||
const list = projectsByArea.get(p.areaId) ?? [];
|
||||
list.push(p);
|
||||
projectsByArea.set(p.areaId, list);
|
||||
}
|
||||
|
||||
const areaSummaries = areas.map((a: Area) => {
|
||||
const projs = projectsByArea.get(a.id) ?? [];
|
||||
const agg = aggregateAreaAmpel(projs);
|
||||
return {
|
||||
id: a.id,
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
ampel: agg.ampel,
|
||||
counts: agg.counts,
|
||||
};
|
||||
});
|
||||
|
||||
const presentToday = new Set(todayPresence.map((p) => p.userId));
|
||||
const usersAll = await ctx.db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.active, true));
|
||||
const fehlende = usersAll.filter((u) => !presentToday.has(u.id));
|
||||
|
||||
return {
|
||||
date: todayDate,
|
||||
areas: areaSummaries,
|
||||
blockers: openBlockers.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
openedAt: b.openedAt,
|
||||
areaId: b.areaId,
|
||||
projectId: b.projectId,
|
||||
})),
|
||||
heuteQuote: {
|
||||
gepingt: presentToday.size,
|
||||
gesamt: totalActiveUsers[0]?.c ?? 0,
|
||||
fehlende,
|
||||
},
|
||||
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
|
||||
pflichtMinimumOffen: allProjects
|
||||
.filter((p) => Object.values(p.pflichtMinimumState).some((v) => v === false))
|
||||
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId })),
|
||||
feed: feedRows,
|
||||
gestern: previousWerktag(todayDate),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
function nextJourfixe(todayIsoDate: string): string {
|
||||
// Nächster Dienstag (Default Jourfixe). Wenn heute Dienstag, dann heute.
|
||||
const d = new Date(`${todayIsoDate}T00:00:00Z`);
|
||||
const wd = d.getUTCDay(); // 0=So..6=Sa
|
||||
const offset = (2 - wd + 7) % 7 || 0; // 2=Di
|
||||
d.setUTCDate(d.getUTCDate() + offset);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { user, dayEntry, absenceDay } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import {
|
||||
todayIso,
|
||||
isWerktag,
|
||||
previousWerktag,
|
||||
parseIsoDate,
|
||||
formatIsoDate,
|
||||
} from '../lib/werktag.js';
|
||||
|
||||
async function computeStreak(db: import('@cockpit/db').Db, userId: string): Promise<number> {
|
||||
const today = todayIso();
|
||||
|
||||
// Hole alle relevanten DayEntries und Abwesenheiten der letzten 90 Tage.
|
||||
const endIso = today;
|
||||
const start = parseIsoDate(today);
|
||||
start.setUTCDate(start.getUTCDate() - 120);
|
||||
const startIso = formatIsoDate(start);
|
||||
|
||||
const [entries, absences] = await Promise.all([
|
||||
db
|
||||
.select({ date: dayEntry.date, status: dayEntry.status })
|
||||
.from(dayEntry)
|
||||
.where(
|
||||
and(eq(dayEntry.userId, userId), gte(dayEntry.date, startIso), lte(dayEntry.date, endIso)),
|
||||
),
|
||||
db
|
||||
.select({ date: absenceDay.date })
|
||||
.from(absenceDay)
|
||||
.where(
|
||||
and(
|
||||
eq(absenceDay.userId, userId),
|
||||
gte(absenceDay.date, startIso),
|
||||
lte(absenceDay.date, endIso),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const fullByDate = new Map<string, boolean>();
|
||||
for (const e of entries) fullByDate.set(e.date, e.status === 'full');
|
||||
const absSet = new Set(absences.map((a) => a.date));
|
||||
|
||||
// Zähle aufeinanderfolgende volle Werktage rückwärts ab heute.
|
||||
let cursor = today;
|
||||
let streak = 0;
|
||||
// Wenn heute noch nicht voll: starten beim vorherigen Werktag.
|
||||
if (!fullByDate.get(today)) {
|
||||
cursor = previousWerktag(today);
|
||||
}
|
||||
while (true) {
|
||||
if (!isWerktag(cursor)) break;
|
||||
if (absSet.has(cursor)) {
|
||||
cursor = previousWerktag(cursor);
|
||||
continue;
|
||||
}
|
||||
if (fullByDate.get(cursor)) {
|
||||
streak++;
|
||||
cursor = previousWerktag(cursor);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export const userRouter = router({
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const streak = await computeStreak(ctx.db, ctx.user.id);
|
||||
return {
|
||||
id: ctx.user.id,
|
||||
name: ctx.user.name,
|
||||
email: ctx.user.email,
|
||||
streak,
|
||||
};
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.active, true));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
markAbsent: protectedProcedure
|
||||
.input(DayEntrySchemas.markAbsentInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.to < input.from) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Endedatum vor Anfangsdatum.' });
|
||||
}
|
||||
// Enumeriere Werktage zwischen from und to (inkl.) und füge sie als absence_day ein.
|
||||
let cursor = input.from;
|
||||
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
|
||||
[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (cursor <= input.to) {
|
||||
if (isWerktag(cursor)) {
|
||||
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });
|
||||
}
|
||||
const next = parseIsoDate(cursor);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
cursor = formatIsoDate(next);
|
||||
}
|
||||
if (toInsert.length === 0) return { inserted: 0 };
|
||||
await ctx.db.insert(absenceDay).values(toInsert).onConflictDoNothing();
|
||||
return { inserted: toInsert.length };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { loadEnv } from './env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { registerAuthMiddleware } from './auth/middleware.js';
|
||||
import { registerAuthRoutes } from './auth/routes.js';
|
||||
import { registerCronRoutes } from './cron/routes.js';
|
||||
import { getDb } from './db.js';
|
||||
import { appRouter, type AppRouter } from './routers/_app.js';
|
||||
import { createContext } from './trpc.js';
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv();
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
base: { service: 'cockpit-api' },
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cookie, {
|
||||
secret: env.SESSION_COOKIE_SECRET,
|
||||
});
|
||||
|
||||
await registerAuthMiddleware(app);
|
||||
await registerAuthRoutes(app);
|
||||
await registerCronRoutes(app);
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ path, error }) {
|
||||
logger.error({ path, err: error }, 'trpc error');
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
const { pool } = getDb();
|
||||
let dbOk = false;
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
dbOk = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'db ping failed');
|
||||
}
|
||||
|
||||
let authentikOk = false;
|
||||
try {
|
||||
const res = await fetch(new URL('.well-known/openid-configuration', env.AUTHENTIK_ISSUER), {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
authentikOk = res.ok;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'authentik ping failed');
|
||||
}
|
||||
|
||||
const status = dbOk && authentikOk ? 'ok' : 'degraded';
|
||||
return {
|
||||
status,
|
||||
db: dbOk ? 'ok' : 'down',
|
||||
authentik: authentikOk ? 'ok' : 'down',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/me', async (req, reply) => {
|
||||
if (!req.cockpitUser) {
|
||||
reply.code(401);
|
||||
return { error: 'Nicht angemeldet.' };
|
||||
}
|
||||
return {
|
||||
id: req.cockpitUser.id,
|
||||
name: req.cockpitUser.name,
|
||||
email: req.cockpitUser.email,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||
logger.info({ port: env.PORT }, 'api server started');
|
||||
} catch (err) {
|
||||
logger.error(err, 'failed to start api server');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
// tRPC v11 Core: Context, initTRPC, Procedure-Typen, Audit-Wrapper.
|
||||
|
||||
import './types.js';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { User } from '@cockpit/db';
|
||||
import { getDb } from './db.js';
|
||||
|
||||
export interface Context {
|
||||
user: User | null;
|
||||
sessionId: string | null;
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
db: ReturnType<typeof getDb>['db'];
|
||||
}
|
||||
|
||||
export async function createContext(opts: {
|
||||
req: FastifyRequest;
|
||||
res: FastifyReply;
|
||||
}): Promise<Context> {
|
||||
const { db } = getDb();
|
||||
return {
|
||||
user: opts.req.cockpitUser,
|
||||
sessionId: opts.req.cockpitSessionId,
|
||||
req: opts.req,
|
||||
reply: opts.res,
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Du bist nicht angemeldet.',
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
// Audit-Wrapper: schreibt nach erfolgreicher Mutation einen audit_event.
|
||||
// Genutzt als zusätzliche Middleware auf Mutations.
|
||||
export function withAudit<TInput, TResult extends Record<string, unknown> | unknown>(
|
||||
action: string,
|
||||
) {
|
||||
return async function audit(opts: {
|
||||
ctx: Context;
|
||||
input: TInput;
|
||||
next: () => Promise<{ ok: true; data: TResult } | TResult>;
|
||||
}) {
|
||||
const result = await opts.next();
|
||||
if (!opts.ctx.user) return result;
|
||||
const { auditEvent } = await import('@cockpit/db');
|
||||
await opts.ctx.db.insert(auditEvent).values({
|
||||
actorUserId: opts.ctx.user.id,
|
||||
action,
|
||||
entityKind: action.split('.')[0] ?? 'unknown',
|
||||
payload: { input: opts.input as unknown },
|
||||
});
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Globale Fastify-Augmentations — wird von jeder Datei mitgezogen, die
|
||||
// `apps/api/src/server.ts` oder via Re-Export-Pfad in Reach kommt.
|
||||
|
||||
import type { User } from '@cockpit/db';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
cockpitUser: User | null;
|
||||
cockpitSessionId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# --- builder ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /repo
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/web ./apps/web
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/db ./packages/db
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# --- runtime (static via Caddy in production compose) ---
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY --from=builder /repo/apps/web/dist /srv
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/toolctrl-emblem.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/favicon.png" />
|
||||
<title>everythingbutwork — ToolCTRL</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cockpit/api": "workspace:*",
|
||||
"@cockpit/db": "workspace:*",
|
||||
"@cockpit/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@trpc/client": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "^11.0.0-rc.660",
|
||||
"@trpc/server": "^11.0.0-rc.660",
|
||||
"@fontsource/inter": "^5.1.1",
|
||||
"@fontsource/jetbrains-mono": "^5.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^7.1.1",
|
||||
"sonner": "^1.7.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 713.32 678.61">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-5);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: url(#linear-gradient-6);
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: url(#linear-gradient-4);
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: url(#linear-gradient-3);
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="658.96" y1="31.96" x2="297.7" y2="458.6" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#000"/>
|
||||
<stop offset="1" stop-color="#333"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="1684.3" y1="1142.75" x2="2206.09" y2="1553.51" gradientTransform="translate(-1833.74 -1108.96) rotate(5.06) skewX(14.25)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#b2b2b2"/>
|
||||
<stop offset=".78" stop-color="#fff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="1719.13" y1="1098.51" x2="2240.91" y2="1509.28" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-4" x1="1764.82" y1="1040.47" x2="2286.6" y2="1451.24" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-5" x1="1806.27" y1="987.82" x2="2328.05" y2="1398.59" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-6" x1="345.03" y1="-10.15" x2="1014.55" y2="516.91" gradientTransform="matrix(1,0,0,1,0,0)" xlink:href="#linear-gradient-2"/>
|
||||
</defs>
|
||||
<g>
|
||||
<path d="M688.7,572.09l-160,98.95c-9.14,5.73-19.99,8.35-30.77,7.37l-380.68-34.36c-22.48-2.01-40.76-19.01-44.35-41.31L.64,159.31c-3.17-19.56,5.6-39.12,22.3-49.72L189.46,8.08c-13.95,9.75,23.77,31.55,26.88,49.95l91.3,361.13c6.21,36.68,36.37,64.58,73.42,67.93l278.64,42.9c8.65.79,21.87,45.68,28.94,42.03l.06.06Z"/>
|
||||
<path class="cls-6" d="M688.64,572.03c-7.07,3.66-15.23,5.36-23.88,4.57l-349-31.5c-37.04-3.35-67.21-31.26-73.42-67.93L170.39,53.35c-3.11-18.4,5.12-35.52,19.07-45.27l1.95-1.16c7.98-5.06,17.61-7.62,27.91-6.76l358.56,30.34c29.49,2.5,53.56,24.62,58.61,53.8l76.16,440.39c3.53,20.29-7.19,38.99-24.01,47.34Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-5" d="M300.22,343.93l-15.01,4.26,15.73,96.05c4.23,25.83,21.88,42.59,47.21,44.83l20.42,1.81-2.48-15.14-20.42-1.81c-15.81-1.4-26.04-11.44-28.79-28.28l-8.24-50.34,44.93,3.97-2.45-14.94-44.93-3.97-5.97-36.43Z"/>
|
||||
<path class="cls-4" d="M421.96,387.97c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM437.36,482.03c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
|
||||
<path class="cls-3" d="M541.37,398.53c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM556.77,492.59c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
|
||||
<polygon class="cls-1" points="607.67 368.41 631.1 511.46 647.04 512.87 622.68 364.15 607.67 368.41"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M582.39,164.07l18.3,1.62-56.29-75.12c-3.15-4.2-7.93-6.86-13.16-7.33h0c-7.61-.67-14.86,3.41-18.22,10.27l-30.3,61.75,19.2,1.7,27.57-56.2c.99-2.01,3.75-2.27,5.1-.48l47.81,63.79Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner';
|
||||
import { TrpcProvider } from './lib/trpc-provider';
|
||||
import { Router } from './lib/router';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<TrpcProvider>
|
||||
<Router />
|
||||
<Toaster position="bottom-right" richColors={false} />
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
export function BlockerDialog({ onClose }: { onClose: () => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.blocker.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.blockerOpened);
|
||||
await utils.puls.overview.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
mutation.mutate({ title: title.trim(), description: description.trim() || undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Was klemmt?</span>
|
||||
<input
|
||||
required
|
||||
ref={(el) => {
|
||||
if (el && !title) el.focus();
|
||||
}}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TopNav } from './TopNav';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
import { BlockerDialog } from '../blocker/BlockerDialog';
|
||||
|
||||
const SHORTCUT_MAP: Record<string, string> = {
|
||||
p: '/puls',
|
||||
h: '/heute',
|
||||
b: '/bereich',
|
||||
e: '/bereich/entwicklung',
|
||||
v: '/bereich/vertrieb',
|
||||
m: '/bereich/marketing',
|
||||
a: '/archiv',
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [blockerOpen, setBlockerOpen] = useState(false);
|
||||
|
||||
// Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen
|
||||
// zeigen wir die Login-Aufforderung mit dem Authentik-Link.
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && me === null) {
|
||||
// Kleiner Delay, damit der useQuery sicher fertig ist und wir nicht
|
||||
// mehrmals redirecten.
|
||||
const t = setTimeout(() => {
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.replace('/api/auth/dev-login');
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [me]);
|
||||
|
||||
// Keyboard-Shortcuts: `g <key>` → Navigation, `n` → Neuer Blocker.
|
||||
useEffect(() => {
|
||||
let waitingForSecond = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function reset() {
|
||||
waitingForSecond = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === '?' && !waitingForSecond) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'n' && !waitingForSecond) {
|
||||
setBlockerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!waitingForSecond && e.key.toLowerCase() === 'g') {
|
||||
waitingForSecond = true;
|
||||
timer = setTimeout(reset, 1500);
|
||||
return;
|
||||
}
|
||||
if (waitingForSecond) {
|
||||
const target = SHORTCUT_MAP[e.key.toLowerCase()];
|
||||
if (target) navigate(target);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
reset();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg)]">
|
||||
<a href="#main" className="skip-link">
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
<TopNav />
|
||||
<main id="main" className="mx-auto max-w-[1280px] px-6 py-8">
|
||||
{me ? <Outlet /> : <NotLoggedInHint />}
|
||||
</main>
|
||||
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotLoggedInHint() {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center shadow-[var(--shadow-sm)]">
|
||||
<p className="text-[var(--color-ink-muted)]">Du bist nicht angemeldet.</p>
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Über Authentik anmelden
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink, useLocation } from 'react-router';
|
||||
import { clsx } from 'clsx';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
|
||||
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
|
||||
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
|
||||
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
|
||||
] as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const me = useCurrentUser();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-30 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
|
||||
role="banner"
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
|
||||
aria-label="Hauptnavigation"
|
||||
>
|
||||
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
|
||||
<span>{voice.appName}</span>
|
||||
<ToolCtrlLogo size={18} />
|
||||
</NavLink>
|
||||
|
||||
<ul className="flex items-center gap-0.5 ml-6">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = location.pathname.startsWith(tab.matchPrefix);
|
||||
return (
|
||||
<li key={tab.to}>
|
||||
<NavLink
|
||||
to={tab.to}
|
||||
className={clsx(
|
||||
'inline-flex h-9 items-center rounded-[var(--radius-md)] px-3 text-sm font-medium',
|
||||
'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
|
||||
isActive &&
|
||||
'text-[var(--color-ink)] border-b-2 border-[var(--color-ink)] rounded-b-none',
|
||||
)}
|
||||
aria-keyshortcuts={tab.shortcut}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{me ? (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
me.streak > 0
|
||||
? 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]'
|
||||
: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
title={`Aktuelle Update-Serie: ${me.streak} ${me.streak === 1 ? 'Tag' : 'Tage'}`}
|
||||
>
|
||||
<Flame size={12} fill={me.streak > 0 ? 'currentColor' : 'none'} />
|
||||
<span className="font-mono tabular-nums">{me.streak}</span>
|
||||
</span>
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
|
||||
aria-label={me.name}
|
||||
title={me.name}
|
||||
>
|
||||
{getInitials(me.name)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="text-sm font-medium text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles: Record<Variant, string> = {
|
||||
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
|
||||
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
|
||||
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
|
||||
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
|
||||
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
|
||||
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'primary', size = 'md', className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(base, variantStyles[variant], sizeStyles[size], className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 pt-6 pb-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Body({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 py-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('px-6 pt-3 pb-6 border-t border-[var(--color-border)]', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Card = { Root, Header, Body, Footer };
|
||||
@@ -0,0 +1,28 @@
|
||||
// macOS-Ctrl-Caret als Brand-Akzent. Skalierbar via currentColor.
|
||||
|
||||
export function KeyCaret({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
>
|
||||
<polyline points="3 10 8 5 13 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function SkeletonCard({ className, lines = 3 }: { className?: string; lines?: number }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] p-6 animate-pulse',
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="h-4 w-1/3 bg-[var(--color-border)] rounded" />
|
||||
<div className="mt-3 space-y-2">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 bg-[var(--color-border)] rounded"
|
||||
style={{ width: `${100 - i * 12}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Brand-Emblem (das Tool-Ctrl-Tastenkappensymbol). Ersetzt den früher
|
||||
// verwendeten Caret als Marken-Anker im Logo-Lockup und in Streaks.
|
||||
|
||||
export function ToolCtrlLogo({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
alt = 'ToolCTRL',
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
alt?: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src="/toolctrl-emblem.svg"
|
||||
width={size}
|
||||
height={(size * 678) / 713}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
alt={ariaHidden ? '' : alt}
|
||||
draggable={false}
|
||||
style={{ display: 'inline-block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { trpc } from '../lib/trpc';
|
||||
|
||||
export function useCurrentUser() {
|
||||
const q = trpc.user.me.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return q.data ?? null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
|
||||
const Puls = lazy(() => import('../routes/puls').then((m) => ({ default: m.PulsRoute })));
|
||||
const Heute = lazy(() =>
|
||||
import('../routes/heute').then((m) => ({ default: m.HeuteRoute })),
|
||||
);
|
||||
const Reflexion = lazy(() =>
|
||||
import('../routes/reflexion').then((m) => ({ default: m.ReflexionRoute })),
|
||||
);
|
||||
const Bereich = lazy(() => import('../routes/bereich').then((m) => ({ default: m.BereichRoute })));
|
||||
const Projekt = lazy(() => import('../routes/projekt').then((m) => ({ default: m.ProjektRoute })));
|
||||
const Archiv = lazy(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })));
|
||||
const NotFound = lazy(() =>
|
||||
import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })),
|
||||
);
|
||||
const Login = lazy(() => import('../routes/login').then((m) => ({ default: m.LoginRoute })));
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
<Suspense fallback={<SkeletonCard className="mx-auto mt-12 max-w-2xl" />}>{node}</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: withSuspense(<Login />),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: withSuspense(<Puls />) },
|
||||
{ path: 'puls', element: withSuspense(<Puls />) },
|
||||
{ path: 'heute', element: withSuspense(<Heute />) },
|
||||
{ path: 'reflexion', element: withSuspense(<Reflexion />) },
|
||||
{ path: 'bereich', element: withSuspense(<Bereich />) },
|
||||
{ path: 'bereich/:slug', element: withSuspense(<Bereich />) },
|
||||
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
|
||||
{ path: 'archiv', element: withSuspense(<Archiv />) },
|
||||
{ path: '*', element: withSuspense(<NotFound />) },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function Router() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from './trpc';
|
||||
import { voice } from './voice';
|
||||
|
||||
export function TrpcProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
onError(err) {
|
||||
const msg =
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'message' in err &&
|
||||
typeof err.message === 'string'
|
||||
? err.message
|
||||
: voice.error.generic;
|
||||
toast(msg);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/trpc',
|
||||
fetch(url, options) {
|
||||
return fetch(url, { ...options, credentials: 'include' });
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { AppRouter } from '@cockpit/api/router';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
@@ -0,0 +1,59 @@
|
||||
// Zentrale UI-Mikrocopy — NFR-12/13 Voice & Tone.
|
||||
// Locker, direkt, deutsch. Keine Anglizismen, keine Corporate-Floskeln.
|
||||
|
||||
export const voice = {
|
||||
appName: 'everythingbutwork',
|
||||
caret: '^',
|
||||
toast: {
|
||||
checkedIn: 'Locked in.',
|
||||
reflected: 'Reflektiert.',
|
||||
blockerOpened: 'Blocker offen.',
|
||||
blockerResolved: 'Blocker erledigt.',
|
||||
projectCreated: 'Projekt angelegt.',
|
||||
projectArchived: 'Projekt archiviert.',
|
||||
saved: 'Gespeichert.',
|
||||
strong: 'Stark.',
|
||||
},
|
||||
cta: {
|
||||
checkIn: 'Lock in',
|
||||
reflect: 'Reflektieren',
|
||||
newBlocker: 'Neuer Blocker',
|
||||
newProject: 'Neues Projekt',
|
||||
},
|
||||
hint: {
|
||||
notCheckedInToday: 'Du hast heute noch nicht gepingt.',
|
||||
reflectionPending: 'Reflexion offen',
|
||||
notReflectedYet: 'Du hast heute noch nicht reflektiert.',
|
||||
howIsItGoing: 'Wie läuft es?',
|
||||
},
|
||||
empty: {
|
||||
feed: 'Hier erscheinen die Updates des Tages, sobald jemand reflektiert hat.',
|
||||
blockers: 'Keine offenen Blocker.',
|
||||
projects: 'Noch keine Projekte. Leg eines an.',
|
||||
archive: 'Noch nichts archiviert.',
|
||||
},
|
||||
error: {
|
||||
generic: 'Da ist etwas schiefgelaufen. Bitte nochmal versuchen.',
|
||||
notLoggedIn: 'Du bist nicht angemeldet.',
|
||||
notAllowed: 'Dafür hast du keine Berechtigung.',
|
||||
notFound: 'Nicht gefunden.',
|
||||
},
|
||||
label: {
|
||||
bereich: 'Bereich',
|
||||
bezug: 'Bezug',
|
||||
ampel: {
|
||||
green: 'Im Plan',
|
||||
amber: 'Gefährdet',
|
||||
red: 'Blockiert',
|
||||
},
|
||||
trend: {
|
||||
better: 'Besser',
|
||||
flat: 'Gleich',
|
||||
worse: 'Schlechter',
|
||||
},
|
||||
projektart: {
|
||||
kundenprojekt: 'Kundenprojekt',
|
||||
internesProjekt: 'Internes Projekt',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@fontsource/inter/latin-300.css';
|
||||
import '@fontsource/inter/latin-400.css';
|
||||
import '@fontsource/inter/latin-500.css';
|
||||
import '@fontsource/inter/latin-600.css';
|
||||
import '@fontsource/inter/latin-700.css';
|
||||
import '@fontsource/jetbrains-mono/latin-400.css';
|
||||
import './styles/globals.css';
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { voice } from '../lib/voice';
|
||||
|
||||
export function ArchivRoute() {
|
||||
const q = trpc.project.listArchived.useQuery();
|
||||
if (q.isPending) return <SkeletonCard lines={4} />;
|
||||
const items = q.data ?? [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">Archiv</h1>
|
||||
{items.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.archive}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((p) => (
|
||||
<Card.Root key={p.id}>
|
||||
<Card.Body className="flex items-center justify-between">
|
||||
<Link to={`/projekt/${p.id}`} className="font-medium hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
archiviert: {p.archiviertAm ? p.archiviertAm.toString().slice(0, 10) : '—'}
|
||||
</span>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Link, NavLink, useParams } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [
|
||||
{ slug: 'entwicklung', label: 'Entwicklung' },
|
||||
{ slug: 'vertrieb', label: 'Vertrieb' },
|
||||
{ slug: 'marketing', label: 'Marketing' },
|
||||
];
|
||||
|
||||
function BereichTabs({ active }: { active: string }) {
|
||||
return (
|
||||
<nav aria-label="Bereich wählen" className="flex gap-1 border-b border-[var(--color-border)]">
|
||||
{BEREICHE.map((b) => (
|
||||
<NavLink
|
||||
key={b.slug}
|
||||
to={`/bereich/${b.slug}`}
|
||||
className={clsx(
|
||||
'inline-flex h-10 items-center rounded-t-[var(--radius-md)] px-4 text-sm font-medium',
|
||||
active === b.slug
|
||||
? 'border-b-2 border-[var(--color-ink)] text-[var(--color-ink)]'
|
||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function BereichRoute() {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
// Default: Entwicklung, wenn /bereich ohne Slug.
|
||||
const effective = slug && BEREICHE.some((b) => b.slug === slug) ? slug : 'entwicklung';
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BereichTabs active={effective} />
|
||||
{effective === 'entwicklung' && <EntwicklungDetail />}
|
||||
{effective === 'vertrieb' && <VertriebDetail />}
|
||||
{effective === 'marketing' && <MarketingDetail />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntwicklungDetail() {
|
||||
const q = trpc.project.byAreaSlug.useQuery({ slug: 'entwicklung' });
|
||||
const areas = trpc.area.list.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={5} />;
|
||||
const projects = q.data ?? [];
|
||||
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
|
||||
const intern = projects.filter((p) => p.kind === 'internes_projekt');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
|
||||
</div>
|
||||
<Section title="Kundenprojekte" projects={kunden} />
|
||||
<Section title="Interne Projekte" projects={intern} />
|
||||
{open && (
|
||||
<ProjectCreateDialog
|
||||
onClose={() => setOpen(false)}
|
||||
areas={areas.data ?? []}
|
||||
defaultSlug="entwicklung"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
projects,
|
||||
}: {
|
||||
title: string;
|
||||
projects: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
statusCache: 'green' | 'amber' | 'red' | null;
|
||||
deadline: string | null;
|
||||
pflichtMinimumState: Record<string, boolean>;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">{title}</h2>
|
||||
{projects.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.projects}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{projects.map((p) => (
|
||||
<Link key={p.id} to={`/projekt/${p.id}`} className="contents">
|
||||
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
|
||||
<Card.Body className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{p.name}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
|
||||
</p>
|
||||
</div>
|
||||
<PflichtMinimumIndicator state={p.pflichtMinimumState} />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PflichtMinimumIndicator({ state }: { state: Record<string, boolean> }) {
|
||||
const points = [
|
||||
state.steckbrief_freigegeben,
|
||||
state.verantwortlicher,
|
||||
state.plane_board,
|
||||
state.meilensteine,
|
||||
state.status_aktuell,
|
||||
state.retrospektive,
|
||||
];
|
||||
const filled = points.filter(Boolean).length;
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={`Pflicht-Minimum ${filled}/6`}>
|
||||
<div className="flex gap-0.5">
|
||||
{points.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={clsx(
|
||||
'h-2 w-2 rounded-sm',
|
||||
p ? 'bg-[var(--color-ink)]' : 'bg-[var(--color-border)]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">{filled}/6</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCreateDialog({
|
||||
onClose,
|
||||
areas,
|
||||
defaultSlug,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
areas: Array<{ id: string; slug: string; name: string }>;
|
||||
defaultSlug: string;
|
||||
}) {
|
||||
const defaultArea = areas.find((a) => a.slug === defaultSlug);
|
||||
const [name, setName] = useState('');
|
||||
const [areaId, setAreaId] = useState(defaultArea?.id ?? '');
|
||||
const [kind, setKind] = useState<'kundenprojekt' | 'internes_projekt'>('internes_projekt');
|
||||
const [arbeitsmodus, setArbeitsmodus] = useState<'cycle' | 'kanban'>('kanban');
|
||||
const [steckbrief, setSteckbrief] = useState('');
|
||||
const [plane, setPlane] = useState('');
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const create = trpc.project.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.projectCreated);
|
||||
await utils.project.byAreaSlug.invalidate();
|
||||
await utils.puls.overview.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!areaId) return;
|
||||
create.mutate({
|
||||
areaId,
|
||||
name,
|
||||
kind,
|
||||
arbeitsmodus,
|
||||
outlineSteckbriefUrl: steckbrief,
|
||||
planeProjectUrl: plane,
|
||||
milestones: [],
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Neues Projekt</h2>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<Field label="Name">
|
||||
<input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bereich">
|
||||
<select
|
||||
required
|
||||
value={areaId}
|
||||
onChange={(e) => setAreaId(e.target.value)}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
{areas.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Projektart">
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as 'kundenprojekt' | 'internes_projekt')}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="kundenprojekt">Kundenprojekt</option>
|
||||
<option value="internes_projekt">Internes Projekt</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Arbeitsmodus">
|
||||
<select
|
||||
value={arbeitsmodus}
|
||||
onChange={(e) => setArbeitsmodus(e.target.value as 'cycle' | 'kanban')}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="kanban">Kanban</option>
|
||||
<option value="cycle">Cycle</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Outline-Steckbrief-URL">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={steckbrief}
|
||||
onChange={(e) => setSteckbrief(e.target.value)}
|
||||
placeholder="https://outline.tool-ctrl.de/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Plane-Board-URL">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={plane}
|
||||
onChange={(e) => setPlane(e.target.value)}
|
||||
placeholder="https://plane.tool-ctrl.de/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function VertriebDetail() {
|
||||
return <ProjektListeFuerBereich slug="vertrieb" />;
|
||||
}
|
||||
|
||||
function MarketingDetail() {
|
||||
return <ProjektListeFuerBereich slug="marketing" />;
|
||||
}
|
||||
|
||||
function ProjektListeFuerBereich({ slug }: { slug: 'vertrieb' | 'marketing' }) {
|
||||
const q = trpc.project.byAreaSlug.useQuery({ slug });
|
||||
const areas = trpc.area.list.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={5} />;
|
||||
const projects = q.data ?? [];
|
||||
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
|
||||
const intern = projects.filter((p) => p.kind === 'internes_projekt');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
|
||||
</div>
|
||||
<Section title="Kundenprojekte" projects={kunden} />
|
||||
<Section title="Interne Projekte" projects={intern} />
|
||||
{open && (
|
||||
<ProjectCreateDialog
|
||||
onClose={() => setOpen(false)}
|
||||
areas={areas.data ?? []}
|
||||
defaultSlug={slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { X, Plus, RotateCw } from 'lucide-react';
|
||||
|
||||
export function HeuteRoute() {
|
||||
const navigate = useNavigate();
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
|
||||
if (today.isPending) return <SkeletonCard lines={4} />;
|
||||
|
||||
if (today.data?.entry) {
|
||||
return <EinstempeltView todos={today.data.todos} entry={today.data.entry} />;
|
||||
}
|
||||
|
||||
return <CheckInForm onDone={() => navigate('/puls')} />;
|
||||
}
|
||||
|
||||
function CheckInForm({ onDone }: { onDone: () => void }) {
|
||||
const carryQuery = trpc.dayEntry.pendingCarryOver.useQuery();
|
||||
const [todos, setTodos] = useState<{ title: string; carried?: boolean }[]>([{ title: '' }]);
|
||||
const [prefilledFromCarry, setPrefilledFromCarry] = useState(false);
|
||||
|
||||
// Wenn Carryover-ToDos geladen sind und das Formular noch leer ist, vorbefüllen.
|
||||
if (
|
||||
!prefilledFromCarry &&
|
||||
carryQuery.data &&
|
||||
carryQuery.data.length > 0 &&
|
||||
todos.length === 1 &&
|
||||
!todos[0]!.title
|
||||
) {
|
||||
setPrefilledFromCarry(true);
|
||||
setTodos([...carryQuery.data.map((c) => ({ title: c.title, carried: true })), { title: '' }]);
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.dayEntry.checkIn.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
const msg =
|
||||
result.carriedOver > 0
|
||||
? `Locked in. ${result.carriedOver} offene ToDos vom Vortag übernommen.`
|
||||
: voice.toast.checkedIn;
|
||||
toast(msg);
|
||||
await utils.dayEntry.today.invalidate();
|
||||
await utils.dayEntry.pendingCarryOver.invalidate();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
function updateAt(i: number, value: string) {
|
||||
setTodos((arr) => arr.map((t, idx) => (idx === i ? { ...t, title: value } : t)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setTodos((arr) => [...arr, { title: '' }]);
|
||||
}
|
||||
|
||||
function removeAt(i: number) {
|
||||
setTodos((arr) => arr.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function handleKey(e: React.KeyboardEvent<HTMLInputElement>, i: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (i === todos.length - 1 && todos[i]?.title.trim()) {
|
||||
addRow();
|
||||
// Fokus auf neue Zeile in nächstem Tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-todo-input]');
|
||||
inputs[i + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const filtered = todos.filter((t) => t.title.trim().length > 0);
|
||||
if (filtered.length === 0) {
|
||||
toast('Mindestens eine ToDo brauchst du.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({ todos: filtered.map((t) => ({ title: t.title.trim() })) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Heute</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
|
||||
</header>
|
||||
<Card.Root>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card.Body className="space-y-3">
|
||||
<p className="text-sm font-medium">Was nimmst du dir heute vor?</p>
|
||||
{carryQuery.data && carryQuery.data.length > 0 && (
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
<RotateCw size={12} className="inline -mt-0.5 mr-1" />
|
||||
{carryQuery.data.length} offene ToDos vom Vortag wurden für dich vorgemerkt.
|
||||
Behalte, ändere oder lösche sie.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{todos.map((t, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
{t.carried && (
|
||||
<span
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-[var(--color-ink-muted)]"
|
||||
title="vom Vortag übernommen"
|
||||
aria-label="vom Vortag übernommen"
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={t.title}
|
||||
onChange={(e) => updateAt(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKey(e, i)}
|
||||
placeholder="ToDo …"
|
||||
maxLength={200}
|
||||
data-todo-input
|
||||
ref={(el) => {
|
||||
if (i === 0 && el && el.value === '') el.focus();
|
||||
}}
|
||||
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
{todos.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
aria-label="ToDo entfernen"
|
||||
className="rounded-[var(--radius-md)] p-2 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Plus size={14} /> Weitere
|
||||
</button>
|
||||
</Card.Body>
|
||||
<Card.Footer className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{voice.cta.checkIn}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EinstempeltView({
|
||||
todos,
|
||||
entry,
|
||||
}: {
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
|
||||
entry: { status: 'begun' | 'full' | 'absent' };
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Heute</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">
|
||||
{entry.status === 'full'
|
||||
? 'Tag abgeschlossen.'
|
||||
: 'Locked in. Heute Abend reflektieren.'}
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{todos.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
t.status === 'done'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ink-faint)]'
|
||||
: 'h-2 w-2 rounded-full bg-[var(--color-border-strong)]'
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
t.status === 'dropped' ? 'line-through text-[var(--color-ink-muted)]' : ''
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
{entry.status !== 'full' && (
|
||||
<Card.Footer className="flex justify-end">
|
||||
<Button onClick={() => (location.href = '/reflexion')}>{voice.cta.reflect}</Button>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { ToolCtrlLogo } from '../components/ui/ToolCtrlLogo';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
session_expired: 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.',
|
||||
oidc_failed: 'Anmeldung über Authentik ist fehlgeschlagen. Bitte erneut versuchen.',
|
||||
};
|
||||
|
||||
export function LoginRoute() {
|
||||
const [params] = useSearchParams();
|
||||
const error = params.get('error');
|
||||
|
||||
// Dev: automatisch in den Dev-Login-Endpoint redirecten — kein Klick nötig.
|
||||
// Authentik kommt später in der Roadmap (siehe docs/operations.md).
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && !error) {
|
||||
window.location.replace('/api/auth/dev-login');
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[var(--color-bg)] px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="inline-flex items-center gap-2 text-4xl font-bold">
|
||||
everythingbutwork <ToolCtrlLogo size={32} />
|
||||
</h1>
|
||||
<p className="mt-3 text-[var(--color-ink-muted)]">
|
||||
Internes ToolCTRL-Projektmanagement.
|
||||
</p>
|
||||
{error && (
|
||||
<p
|
||||
className="mt-4 rounded-[var(--radius-md)] border border-[var(--color-ampel-red-bg)] bg-[var(--color-ampel-red-bg)] px-4 py-2 text-sm text-[var(--color-ampel-red-fg)]"
|
||||
role="alert"
|
||||
>
|
||||
{ERROR_MESSAGES[error] ?? 'Anmeldung fehlgeschlagen.'}
|
||||
</p>
|
||||
)}
|
||||
{!import.meta.env.DEV && (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-6 inline-flex h-11 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-6 text-base font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Über Authentik anmelden
|
||||
</a>
|
||||
)}
|
||||
{import.meta.env.DEV && (
|
||||
<p className="mt-4 text-xs text-[var(--color-ink-muted)]">
|
||||
Dev-Modus: Auto-Login wird vorgenommen…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export function NotFoundRoute() {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold">Nicht gefunden.</h1>
|
||||
<p className="mt-2 text-[var(--color-ink-muted)]">
|
||||
Diese Seite gibt es nicht. Vielleicht meintest du den Puls?
|
||||
</p>
|
||||
<Link
|
||||
to="/puls"
|
||||
className="mt-6 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Zum Puls
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { useCurrentUser } from '../hooks/useCurrentUser';
|
||||
import { voice } from '../lib/voice';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function ProjektRoute() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const q = trpc.project.byId.useQuery({ id });
|
||||
const [abschlussOpen, setAbschlussOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={6} />;
|
||||
if (!q.data) return null;
|
||||
const { project: p, area: a, milestones } = q.data;
|
||||
const isP1 = me?.id === p.verantwortlicheUserId;
|
||||
const pflichtKeys: Array<{ key: string; label: string }> = [
|
||||
{ key: 'steckbrief_freigegeben', label: 'Steckbrief in Outline freigegeben' },
|
||||
{ key: 'verantwortlicher', label: 'Projektverantwortliche/r gesetzt' },
|
||||
{ key: 'plane_board', label: 'Plane-Board verlinkt' },
|
||||
{ key: 'meilensteine', label: 'Meilensteine vorhanden' },
|
||||
{ key: 'status_aktuell', label: 'Status-Ampel aktuell (≤ 7 Werktage)' },
|
||||
{ key: 'retrospektive', label: 'Abschluss-Retrospektive (bei beendeten Projekten)' },
|
||||
];
|
||||
|
||||
function goBack() {
|
||||
// Wenn der History-Stack einen Vorgänger hat, dorthin; sonst auf den Bereich.
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate(`/bereich/${a.slug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label="Zurück"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
<Link to="/puls" className="hover:underline">
|
||||
Puls
|
||||
</Link>
|
||||
{' › '}
|
||||
<Link to={`/bereich/${a.slug}`} className="hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold">{p.name}</h1>
|
||||
{isP1 && !p.archiviert && (
|
||||
<Button variant="secondary" onClick={() => setAbschlussOpen(true)}>
|
||||
Projekt abschließen
|
||||
</Button>
|
||||
)}
|
||||
{p.archiviert && (
|
||||
<span className="rounded-full bg-black/5 px-2.5 py-1 text-xs">archiviert</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
{p.kind === 'kundenprojekt' ? 'Kundenprojekt' : 'Internes Projekt'} ·{' '}
|
||||
{p.arbeitsmodus ?? '—'} · {p.deadline ? `Deadline ${p.deadline}` : 'kein Deadline'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{abschlussOpen && (
|
||||
<AbschlussDialog projectId={p.id} onClose={() => setAbschlussOpen(false)} />
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Verlinkungen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body className="space-y-2 text-sm">
|
||||
<LinkRow label="Outline-Steckbrief" url={p.outlineSteckbriefUrl} />
|
||||
<LinkRow label="Plane-Board" url={p.planeProjectUrl} />
|
||||
<LinkRow label="Risikoliste" url={p.risikolisteUrl} />
|
||||
<LinkRow label="Lessons Learned" url={p.lessonsLearnedUrl} />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Pflicht-Minimum</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{pflichtKeys.map(({ key, label }) => {
|
||||
const ok = p.pflichtMinimumState[key] === true;
|
||||
return (
|
||||
<li key={key} className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block h-2.5 w-2.5 rounded-full',
|
||||
ok ? 'bg-[var(--color-ampel-green-fg)]' : 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span className={ok ? '' : 'text-[var(--color-ink-muted)]'}>{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">Meilensteine</h2>
|
||||
{milestones.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Meilensteine.</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{milestones.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
m.status === 'done' && 'line-through text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{m.title}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
{m.due ?? '—'} · {m.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{p.dodText && (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Definition of Done</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p className="whitespace-pre-wrap text-sm">{p.dodText}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: () => void }) {
|
||||
const [url, setUrl] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.project.archive.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.projectArchived);
|
||||
await utils.project.byId.invalidate({ id: projectId });
|
||||
await utils.project.listArchived.invalidate();
|
||||
await utils.puls.overview.invalidate();
|
||||
navigate('/archiv');
|
||||
},
|
||||
});
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ projectId, lessonsLearnedUrl: url });
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Projekt abschließen</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
|
||||
Lessons-Learned-URL ist Pflicht (Leitfaden §9 Punkt 6).
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://outline.tool-ctrl.de/lessons-learned/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Abschließen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ label, url }: { label: string; url: string | null }) {
|
||||
return (
|
||||
<p className="flex items-center justify-between">
|
||||
<span className="text-[var(--color-ink-muted)]">{label}</span>
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="truncate hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--color-ink-faint)]">—</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Link } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red' | 'grey';
|
||||
|
||||
export function PulsRoute() {
|
||||
const q = trpc.puls.overview.useQuery();
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
|
||||
if (q.isPending) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!q.data) return null;
|
||||
|
||||
const isCheckedIn =
|
||||
today.data?.entry?.status !== undefined && today.data.entry.status !== 'absent';
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Unternehmens-Puls</h1>
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
Stand: {formatDate(q.data.date)} · Jourfixe {q.data.jourfixe.label}
|
||||
</p>
|
||||
</div>
|
||||
{!isCheckedIn && (
|
||||
<Link
|
||||
to="/heute"
|
||||
className="inline-flex h-11 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-5 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
{voice.hint.notCheckedInToday} → {voice.cta.checkIn}
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="bereiche-heading">
|
||||
<h2 id="bereiche-heading" className="sr-only">
|
||||
Bereiche
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{q.data.areas.map((a) => (
|
||||
<Link key={a.id} to={`/bereich/${a.slug}`} className="contents">
|
||||
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)] transition-shadow">
|
||||
<Card.Body className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{a.name}</h3>
|
||||
<AmpelDot ampel={a.ampel as Ampel} />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-[var(--color-ink-muted)]">
|
||||
<span>{a.counts.green} grün</span>
|
||||
<span>{a.counts.amber} gelb</span>
|
||||
<span>{a.counts.red} rot</span>
|
||||
{a.counts.grey > 0 && <span>{a.counts.grey} ohne Status</span>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Heute</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p className="text-2xl font-bold">
|
||||
{q.data.heuteQuote.gepingt} / {q.data.heuteQuote.gesamt}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Locked in</p>
|
||||
{q.data.heuteQuote.fehlende.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]">
|
||||
{q.data.heuteQuote.fehlende.length} fehlt heute noch
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 text-sm">
|
||||
{q.data.heuteQuote.fehlende.map((u) => (
|
||||
<li key={u.id}>{u.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root className="md:col-span-2">
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Bereichsübergreifend offen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{q.data.blockers.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.blockers}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{q.data.blockers.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{b.title}</span>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
seit {formatDate(b.openedAt.toString())}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
|
||||
{q.data.pflichtMinimumOffen.length > 0 && (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Pflicht-Minimum offen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{q.data.pflichtMinimumOffen.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link to={`/projekt/${p.id}`} className="hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
<TeamHeuteSection />
|
||||
|
||||
<section aria-labelledby="feed-heading">
|
||||
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
|
||||
Gestern im Team
|
||||
</h2>
|
||||
{q.data.feed.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.feed}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{q.data.feed.map((entry) => (
|
||||
<Card.Root key={entry.id}>
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{entry.userName}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">{entry.date}</p>
|
||||
</div>
|
||||
{entry.ampel && <AmpelBadge ampel={entry.ampel} />}
|
||||
</div>
|
||||
{entry.reflectionText && <p className="mt-2 text-sm">{entry.reflectionText}</p>}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AmpelDot({ ampel }: { ampel: Ampel }) {
|
||||
const colorClass = {
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
grey: 'bg-[var(--color-ink-faint)]',
|
||||
}[ampel];
|
||||
return <span className={clsx('inline-block h-3 w-3 rounded-full', colorClass)} aria-hidden />;
|
||||
}
|
||||
|
||||
function AmpelBadge({ ampel }: { ampel: 'green' | 'amber' | 'red' }) {
|
||||
const cls = {
|
||||
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
|
||||
}[ampel];
|
||||
const label = { green: 'Im Plan', amber: 'Gefährdet', red: 'Blockiert' }[ampel];
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
cls,
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamHeuteSection() {
|
||||
const team = trpc.dayEntry.todayTeam.useQuery();
|
||||
const users = trpc.user.list.useQuery();
|
||||
if (team.isPending) return null;
|
||||
const data = team.data ?? [];
|
||||
const userMap = new Map((users.data ?? []).map((u) => [u.id, u.name]));
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Heute hat sich noch niemand locked in.
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((u) => (
|
||||
<Card.Root key={u.userId}>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{userMap.get(u.userId) ?? '—'}</p>
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
u.entryStatus === 'full'
|
||||
? 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]'
|
||||
: u.entryStatus === 'begun'
|
||||
? 'bg-black/5 text-[var(--color-ink-muted)]'
|
||||
: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{u.entryStatus === 'full'
|
||||
? 'reflektiert'
|
||||
: u.entryStatus === 'begun'
|
||||
? 'locked in'
|
||||
: 'abwesend'}
|
||||
</span>
|
||||
</div>
|
||||
{u.todos.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-[var(--color-ink-faint)]">Keine ToDos eingetragen.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5 text-sm">
|
||||
{u.todos.map((t) => (
|
||||
<li key={t.id} className="flex items-start gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-1 inline-block h-2 w-2 rounded-full shrink-0',
|
||||
t.status === 'done'
|
||||
? 'bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'bg-[var(--color-ink-faint)]'
|
||||
: 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
t.status === 'dropped' && 'line-through text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(input: string | Date): string {
|
||||
const dt = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(dt.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(dt);
|
||||
}
|
||||
|
||||
// Button als referenz behalten (Linter)
|
||||
void Button;
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
import { Check, X, Minus, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red';
|
||||
type Trend = 'besser' | 'gleich' | 'schlechter';
|
||||
type TodoStatus = 'open' | 'done' | 'dropped';
|
||||
|
||||
export function ReflexionRoute() {
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (today.isPending) return <SkeletonCard lines={6} />;
|
||||
if (!today.data?.entry) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
Du bist heute noch nicht locked in. {voice.hint.notCheckedInToday}
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => navigate('/heute')}>
|
||||
Zum Lock-in
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ReflectForm
|
||||
entry={today.data.entry}
|
||||
todos={today.data.todos}
|
||||
onDone={() => navigate('/puls')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReflectForm({
|
||||
entry,
|
||||
todos,
|
||||
onDone,
|
||||
}: {
|
||||
entry: { id: string };
|
||||
todos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: TodoStatus;
|
||||
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
||||
}>;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [todoStates, setTodoStates] = useState<
|
||||
Record<string, { status: TodoStatus; reason?: string }>
|
||||
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
|
||||
const [reflectionText, setReflectionText] = useState('');
|
||||
const [ampel, setAmpel] = useState<Ampel | null>(null);
|
||||
const [trend, setTrend] = useState<Trend | null>(null);
|
||||
const [blockerEnabled, setBlockerEnabled] = useState(false);
|
||||
const [blockerTitle, setBlockerTitle] = useState('');
|
||||
const [blockerDescription, setBlockerDescription] = useState('');
|
||||
|
||||
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.dayEntry.submitReflection.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.reflected);
|
||||
await utils.dayEntry.today.invalidate();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
function setTodoStatus(id: string, status: TodoStatus) {
|
||||
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ampel) {
|
||||
toast('Bitte Ampel setzen.');
|
||||
return;
|
||||
}
|
||||
if (hasProjectBezug && !trend) {
|
||||
toast('Bei Projektbezug ist der Trend Pflicht.');
|
||||
return;
|
||||
}
|
||||
if (blockerEnabled && !blockerTitle.trim()) {
|
||||
toast('Blocker braucht einen Titel.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
dayEntryId: entry.id,
|
||||
todos: Object.entries(todoStates).map(([id, s]) => ({
|
||||
id,
|
||||
status: s.status,
|
||||
dropReason: s.reason,
|
||||
})),
|
||||
reflectionText,
|
||||
ampel,
|
||||
trend: trend ?? null,
|
||||
...(blockerEnabled
|
||||
? { blocker: { title: blockerTitle, description: blockerDescription || undefined } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Reflexion</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Was ist heute passiert?</h2>
|
||||
</Card.Header>
|
||||
<Card.Body className="space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--color-ink-muted)]">
|
||||
Deine ToDos vom Morgen
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{todos.map((t) => {
|
||||
const status = todoStates[t.id]?.status ?? 'open';
|
||||
return (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm">{t.title}</span>
|
||||
<div className="flex gap-1">
|
||||
<StatusBtn
|
||||
active={status === 'done'}
|
||||
onClick={() => setTodoStatus(t.id, 'done')}
|
||||
label="Erledigt"
|
||||
>
|
||||
<Check size={14} />
|
||||
</StatusBtn>
|
||||
<StatusBtn
|
||||
active={status === 'open'}
|
||||
onClick={() => setTodoStatus(t.id, 'open')}
|
||||
label="Offen"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</StatusBtn>
|
||||
<StatusBtn
|
||||
active={status === 'dropped'}
|
||||
onClick={() => setTodoStatus(t.id, 'dropped')}
|
||||
label="Verworfen"
|
||||
>
|
||||
<X size={14} />
|
||||
</StatusBtn>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-3">
|
||||
<label className="block text-sm font-medium" htmlFor="reflection">
|
||||
Sonst noch was?
|
||||
</label>
|
||||
<textarea
|
||||
id="reflection"
|
||||
value={reflectionText}
|
||||
onChange={(e) => setReflectionText(e.target.value)}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-ink-faint)] text-right">
|
||||
{reflectionText.length} / 1000
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-4">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">Ampel</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<AmpelBtn
|
||||
value="green"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.green}
|
||||
/>
|
||||
<AmpelBtn
|
||||
value="amber"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.amber}
|
||||
/>
|
||||
<AmpelBtn
|
||||
value="red"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.red}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
Trend {hasProjectBezug && <span aria-hidden>*</span>}
|
||||
<span className="ml-1 text-xs text-[var(--color-ink-faint)]">
|
||||
{hasProjectBezug ? '(Pflicht bei Projektbezug)' : '(optional)'}
|
||||
</span>
|
||||
</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<TrendBtn value="besser" current={trend} onChange={setTrend}>
|
||||
<TrendingUp size={14} /> Besser
|
||||
</TrendBtn>
|
||||
<TrendBtn value="gleich" current={trend} onChange={setTrend}>
|
||||
<ArrowRight size={14} /> Gleich
|
||||
</TrendBtn>
|
||||
<TrendBtn value="schlechter" current={trend} onChange={setTrend}>
|
||||
<TrendingDown size={14} /> Schlechter
|
||||
</TrendBtn>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blockerEnabled}
|
||||
onChange={(e) => setBlockerEnabled(e.target.checked)}
|
||||
/>
|
||||
Blocker?
|
||||
</label>
|
||||
{blockerEnabled && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={blockerTitle}
|
||||
onChange={(e) => setBlockerTitle(e.target.value)}
|
||||
placeholder="Was klemmt?"
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={blockerDescription}
|
||||
onChange={(e) => setBlockerDescription(e.target.value)}
|
||||
placeholder="Mehr Kontext (optional)"
|
||||
rows={2}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{voice.cta.reflect}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBtn({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border',
|
||||
active
|
||||
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AmpelBtn({
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: Ampel;
|
||||
current: Ampel | null;
|
||||
onChange: (a: Ampel) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const bgMap: Record<Ampel, string> = {
|
||||
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
|
||||
};
|
||||
const dotMap: Record<Ampel, string> = {
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
};
|
||||
const active = current === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={clsx(
|
||||
'flex h-12 items-center gap-2 rounded-[var(--radius-md)] px-3 text-sm font-medium border',
|
||||
active
|
||||
? bgMap[value] + ' border-[var(--color-ink)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
<span className={clsx('h-2.5 w-2.5 rounded-full', dotMap[value])} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBtn({
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
value: Trend;
|
||||
current: Trend | null;
|
||||
onChange: (t: Trend) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = current === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={clsx(
|
||||
'flex h-10 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border px-3 text-sm font-medium',
|
||||
active
|
||||
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ------ Brand Tokens (UX-Spec § Visual Design Foundation) ------- */
|
||||
|
||||
@theme {
|
||||
/* Neutrals */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #fafafa;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-strong: #d4d4d4;
|
||||
--color-ink: #0a0a0a;
|
||||
--color-ink-muted: #525252;
|
||||
--color-ink-faint: #a3a3a3;
|
||||
--color-brand: #0a0a0a;
|
||||
--color-brand-fg: #ffffff;
|
||||
|
||||
/* Ampel */
|
||||
--color-ampel-green-fg: #15803d;
|
||||
--color-ampel-green-bg: #dcfce7;
|
||||
--color-ampel-amber-fg: #a16207;
|
||||
--color-ampel-amber-bg: #fef3c7;
|
||||
--color-ampel-red-fg: #b91c1c;
|
||||
--color-ampel-red-bg: #fee2e2;
|
||||
|
||||
/* Trend */
|
||||
--color-trend-up: #15803d;
|
||||
--color-trend-flat: #525252;
|
||||
--color-trend-down: #b91c1c;
|
||||
|
||||
/* Typografie */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(10, 10, 10, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(10, 10, 10, 0.06);
|
||||
--shadow-lg: 0 12px 32px rgba(10, 10, 10, 0.1);
|
||||
}
|
||||
|
||||
/* ------ Base styles ------- */
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-ink);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reusable utility classes (NFR-9 — ≤ 3 Klicks Workflow). */
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-brand-fg);
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 7173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:7100',
|
||||
'/trpc': 'http://localhost:7100',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
# Development-only services. App und API laufen lokal via pnpm,
|
||||
# nur Postgres wird containerisiert für eine konsistente Datenbasis.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cockpit-postgres-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: cockpit
|
||||
POSTGRES_PASSWORD: cockpit_dev
|
||||
POSTGRES_DB: cockpit
|
||||
TZ: Europe/Berlin
|
||||
ports:
|
||||
- '55432:5432'
|
||||
volumes:
|
||||
- cockpit_postgres_dev:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U cockpit -d cockpit']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
cockpit_postgres_dev:
|
||||
name: cockpit_postgres_dev
|
||||
@@ -0,0 +1,85 @@
|
||||
# Production Docker-Compose. Deployment auf ToolCTRL-interne Infrastruktur.
|
||||
# Erforderliche Env-Datei: .env.production (per Docker-Secret-Mount oder
|
||||
# Compose-Override).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cockpit-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-cockpit}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cockpit}
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- cockpit_postgres_data:/var/lib/postgresql/data
|
||||
- cockpit_backups:/backups
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER:-cockpit} -d $${POSTGRES_DB:-cockpit}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: cockpit-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-cockpit}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-cockpit}
|
||||
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||
AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI}
|
||||
SESSION_COOKIE_SECRET: ${SESSION_COOKIE_SECRET}
|
||||
CRON_BEARER_TOKEN: ${CRON_BEARER_TOKEN}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://cockpit.tool-ctrl.de}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
PORT: 3000
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
container_name: cockpit-web
|
||||
restart: unless-stopped
|
||||
# Web ist statisches Asset-Image; Caddy mountet /srv aus diesem Container.
|
||||
# In Compose teilen wir es via volume statt named-host.
|
||||
volumes:
|
||||
- cockpit_web_dist:/srv
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: cockpit-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
environment:
|
||||
COCKPIT_HOST: ${COCKPIT_HOST:-cockpit.tool-ctrl.de}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- cockpit_web_dist:/srv:ro
|
||||
- cockpit_caddy_data:/data
|
||||
- cockpit_caddy_config:/config
|
||||
|
||||
volumes:
|
||||
cockpit_postgres_data:
|
||||
cockpit_backups:
|
||||
cockpit_web_dist:
|
||||
cockpit_caddy_data:
|
||||
cockpit_caddy_config:
|
||||
@@ -0,0 +1,81 @@
|
||||
# Operations Guide — ToolCTRL everythingbutwork
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
# Postgres (Docker)
|
||||
docker compose -f docker-compose.dev.yml up -d postgres
|
||||
|
||||
# Migration anwenden
|
||||
cp .env.example .env
|
||||
pnpm --filter @cockpit/db migrate:up
|
||||
|
||||
# API und Web
|
||||
pnpm --filter api dev # :3000
|
||||
pnpm --filter web dev # :5173 (proxy /api+/trpc → :3000)
|
||||
```
|
||||
|
||||
## Authentik-Setup (Story 1.3)
|
||||
|
||||
1. In Authentik (auth.tool-ctrl.de) als Admin einloggen.
|
||||
2. **Providers → Create → OAuth2/OpenID Provider**
|
||||
- Name: `cockpit`
|
||||
- Authorization flow: `default-provider-authorization-explicit-consent`
|
||||
- Client type: `Confidential`
|
||||
- Client ID + Secret automatisch generieren lassen, in `.env` übertragen.
|
||||
- Signing key: `authentik Self-signed Certificate`
|
||||
- Redirect URIs: `https://cockpit.tool-ctrl.de/api/auth/callback` (Prod), `http://localhost:3000/api/auth/callback` (Dev)
|
||||
3. **Applications → Create**
|
||||
- Name: `everythingbutwork`
|
||||
- Slug: `cockpit`
|
||||
- Provider: `cockpit`
|
||||
- Launch URL: `https://cockpit.tool-ctrl.de/`
|
||||
4. `.env`-Variablen ausfüllen:
|
||||
```
|
||||
AUTHENTIK_ISSUER=https://auth.tool-ctrl.de/application/o/cockpit/
|
||||
AUTHENTIK_CLIENT_ID=<aus Authentik>
|
||||
AUTHENTIK_CLIENT_SECRET=<aus Authentik>
|
||||
```
|
||||
5. Gruppen-Mapping (OQ-1) wird im API-Code per `AUTHENTIK_GROUP_TO_AREA`-Variable umgesetzt (Erweiterung in späterer Story).
|
||||
|
||||
## Production-Deployment
|
||||
|
||||
```bash
|
||||
# Auf der ToolCTRL-Infra:
|
||||
git pull
|
||||
docker compose pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Migration:
|
||||
docker compose exec api node -e "import('@cockpit/db/client').then(async ({ createDbClient }) => { /* migrate via drizzle-kit in CI */ })"
|
||||
# Alternativ direkt via drizzle-kit von außen mit Production-DATABASE_URL.
|
||||
```
|
||||
|
||||
## Backups (Story 6.3)
|
||||
|
||||
Tägliches `pg_dump` via Cron-Endpoint `POST /internal/cron/pg-dump-backup` mit Bearer-Token.
|
||||
14 Tage Retention. Restore-Drill quartalsweise.
|
||||
|
||||
### Restore-Anleitung
|
||||
|
||||
1. API stoppen: `docker compose stop api`
|
||||
2. Backup aus `/backups` auswählen.
|
||||
3. Restore in temporäre DB: `docker compose exec postgres pg_restore -U cockpit -d cockpit_restore /backups/<file>.sql.gz`
|
||||
4. Smoke-Test: `curl http://localhost:3000/api/health`
|
||||
5. API wieder starten: `docker compose start api`
|
||||
|
||||
## Healthcheck
|
||||
|
||||
`GET /api/health` liefert:
|
||||
|
||||
```json
|
||||
{ "status": "ok|degraded", "db": "ok|down", "authentik": "ok|down", "timestamp": "..." }
|
||||
```
|
||||
|
||||
Wird von Docker und (Phase 2) externer Uptime-Überwachung genutzt.
|
||||
|
||||
## OQ-5 (Archivierung)
|
||||
|
||||
Soft-Delete via `project.archiviert=true` + `archiviert_am`. Kein automatisches Löschen.
|
||||
Manuelle Bereinigung bei Bedarf durch DB-Admin via Migration.
|
||||
@@ -0,0 +1,55 @@
|
||||
// Flat config (ESLint 9+). Root config; per-app configs extend this implicitly via parser/plugins.
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'_bmad/**',
|
||||
'_bmad-output/**',
|
||||
'ToolCTRL Brand Identity/**',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['apps/web/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/consistent-type-imports': 'warn',
|
||||
// Modal-Backdrops sind Click-Catcher; sie sind a11y-tauglich (Esc + sichtbarer
|
||||
// Close-Button), die jsx-a11y-Regeln rufen aber Falsch-Positive.
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "cockpit",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "ToolCTRL Unternehmens-Cockpit — internal project overview tool",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "pnpm -r --parallel run typecheck",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "pnpm -r --parallel run test",
|
||||
"build": "pnpm -r run build",
|
||||
"dev:web": "pnpm --filter web dev",
|
||||
"dev:api": "pnpm --filter api dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/schema.ts',
|
||||
out: './migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgres://cockpit:cockpit_dev@localhost:55432/cockpit',
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
} satisfies Config;
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user