From a1cf6f3883a9546ee704571e86dee5daa6aedbe7 Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 7 Jun 2026 17:50:07 +0200 Subject: [PATCH] =?UTF-8?q?chore(format):=20Prettier=20=C3=BCber=20das=20g?= =?UTF-8?q?esamte=20Repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behebt die rote CI (format:check schlug an 36 Alt-Bestand-Dateien fehl: migrations/meta-Snapshots, seed.ts, schemas/lead.ts, README u. a.) und erzwingt nebenbei einen frischen web-Build beim Auto-Deploy (apps/web-Dateien geaendert -> COPY-Layer invalidiert -> Vite baut neu). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 153 ++++---- apps/api/src/auth/routes.ts | 9 +- apps/api/src/lib/kimai.ts | 5 +- apps/api/src/routers/change.ts | 3 +- apps/api/src/routers/dashboard.ts | 144 +++---- apps/api/src/routers/deal.ts | 3 +- apps/api/src/routers/risk.ts | 84 ++-- apps/api/src/routers/stakeholder.ts | 6 +- .../src/components/project/ProjectLogo.tsx | 4 +- .../src/components/time/TeamTimeSection.tsx | 16 +- apps/web/src/components/time/TimerPill.tsx | 4 +- apps/web/src/components/ui/Card.tsx | 5 +- apps/web/src/lib/lead-meta.ts | 5 +- apps/web/src/lib/project-meta.ts | 37 +- apps/web/src/routes/dashboard.tsx | 13 +- apps/web/src/routes/login.tsx | 4 +- apps/web/src/routes/team-view.tsx | 13 +- apps/web/src/styles/globals.css | 12 +- .../db/migrations/meta/0004_snapshot.json | 170 +++----- .../db/migrations/meta/0005_snapshot.json | 170 +++----- .../db/migrations/meta/0006_snapshot.json | 194 +++------- .../db/migrations/meta/0007_snapshot.json | 194 +++------- .../db/migrations/meta/0008_snapshot.json | 250 +++--------- .../db/migrations/meta/0009_snapshot.json | 274 ++++--------- .../db/migrations/meta/0010_snapshot.json | 330 ++++------------ .../db/migrations/meta/0011_snapshot.json | 338 ++++------------ .../db/migrations/meta/0012_snapshot.json | 338 ++++------------ .../db/migrations/meta/0013_snapshot.json | 338 ++++------------ .../db/migrations/meta/0014_snapshot.json | 338 ++++------------ .../db/migrations/meta/0015_snapshot.json | 338 ++++------------ .../db/migrations/meta/0016_snapshot.json | 338 ++++------------ .../db/migrations/meta/0017_snapshot.json | 362 +++++------------- .../db/migrations/meta/0018_snapshot.json | 362 +++++------------- packages/db/migrations/meta/_journal.json | 2 +- packages/db/src/seed.ts | 208 ++++++++-- packages/shared/src/schemas/lead.ts | 5 +- 36 files changed, 1559 insertions(+), 3510 deletions(-) diff --git a/README.md b/README.md index 657625a..806208a 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro ## Was die App tut -| Bereich | Feature | -|---|---| -| **Dashboard** (`/dashboard`) | Unternehmens-Übersicht: Bereichs-Ampelstatus, **Showstopper** (offene Showstopper-Risiken), **Projekte in Eskalation/Taskforce**, „Heute fehlt"-Liste, bereichsübergreifende Blocker, Pflicht-Minimum-Übersicht, Jourfixe-Hinweis, „Heute im Team" (ToDos), „Zeit im Team heute" (Live-Tracking, Wall-Clock), Vortags-Feed | -| **Bereich** (`/bereich`, `/bereich/`) | Tab-Switcher Entwicklung / Vertrieb / Marketing — Projekt-Liste (Kundenprojekte / Interne Projekte) mit Pflicht-Minimum- & Modus-Indicator. **Vertrieb** zusätzlich: Lead-Teaser + „Projekte mit Vertriebsaktivität" (bereichsübergreifend) | -| **Projekt** (`/projekt/:id`) | Detail: Ziel/Maxime/Nicht-Ziele/Liefergegenstände + Zieldreieck, **Phasen-Lebenszyklus mit Gate-Reviews**, **Statusbericht** (Ampel/Trend + Verlauf), **Stakeholder-Matrix**, **Risiko- & Chancen-Register**, **verknüpfte Leads**, **Änderungs-Log**, Verlinkungen, Pflicht-Minimum, Meilensteine (mit Ergebnis/Kundennutzen + Datums-Warnung), DoD, Projekt-Abschluss (Checkliste + Vermerk, P1), Projekt-Modus | -| **Leads** (`/leads`, `/lead/:id`) | Vertriebs-CRM: Lead-Liste nach Status (Cold/Warm/Hot/Won/Lost), Dossier (Stammdaten, Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log, **CSV-Import**, Lead→Deal-Promotion, **Projekt-Verknüpfung** | -| **Heute** (`/heute`) | Tagesrhythmus: Lock-in mit ToDo-Liste, Auto-Carryover offener ToDos vom Vortag, **ToDos mitten am Tag hinzufügen**, Timer pro ToDo, Zeiterfassungs-Timeline mit Edit/Delete/Nachtrag | -| **Reflexion** (`/reflexion`) | Abend-Reflexion: ToDos abhaken + **Titel inline korrigieren**, Freitext, Ampel + Trend, optional Blocker. Editierbar bis 00:00 Berlin | -| **Archiv** (`/archiv`) | Abgeschlossene Projekte, gruppiert nach Bereich | +| Bereich | Feature | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Dashboard** (`/dashboard`) | Unternehmens-Übersicht: Bereichs-Ampelstatus, **Showstopper** (offene Showstopper-Risiken), **Projekte in Eskalation/Taskforce**, „Heute fehlt"-Liste, bereichsübergreifende Blocker, Pflicht-Minimum-Übersicht, Jourfixe-Hinweis, „Heute im Team" (ToDos), „Zeit im Team heute" (Live-Tracking, Wall-Clock), Vortags-Feed | +| **Bereich** (`/bereich`, `/bereich/`) | Tab-Switcher Entwicklung / Vertrieb / Marketing — Projekt-Liste (Kundenprojekte / Interne Projekte) mit Pflicht-Minimum- & Modus-Indicator. **Vertrieb** zusätzlich: Lead-Teaser + „Projekte mit Vertriebsaktivität" (bereichsübergreifend) | +| **Projekt** (`/projekt/:id`) | Detail: Ziel/Maxime/Nicht-Ziele/Liefergegenstände + Zieldreieck, **Phasen-Lebenszyklus mit Gate-Reviews**, **Statusbericht** (Ampel/Trend + Verlauf), **Stakeholder-Matrix**, **Risiko- & Chancen-Register**, **verknüpfte Leads**, **Änderungs-Log**, Verlinkungen, Pflicht-Minimum, Meilensteine (mit Ergebnis/Kundennutzen + Datums-Warnung), DoD, Projekt-Abschluss (Checkliste + Vermerk, P1), Projekt-Modus | +| **Leads** (`/leads`, `/lead/:id`) | Vertriebs-CRM: Lead-Liste nach Status (Cold/Warm/Hot/Won/Lost), Dossier (Stammdaten, Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log, **CSV-Import**, Lead→Deal-Promotion, **Projekt-Verknüpfung** | +| **Heute** (`/heute`) | Tagesrhythmus: Lock-in mit ToDo-Liste, Auto-Carryover offener ToDos vom Vortag, **ToDos mitten am Tag hinzufügen**, Timer pro ToDo, Zeiterfassungs-Timeline mit Edit/Delete/Nachtrag | +| **Reflexion** (`/reflexion`) | Abend-Reflexion: ToDos abhaken + **Titel inline korrigieren**, Freitext, Ampel + Trend, optional Blocker. Editierbar bis 00:00 Berlin | +| **Archiv** (`/archiv`) | Abgeschlossene Projekte, gruppiert nach Bereich | **Querschnittsfunktionen** @@ -32,23 +32,23 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro ## Tech Stack -| Layer | Wahl | -|---|---| -| Sprache | **TypeScript** strict, ESM überall | -| Repo-Layout | **pnpm**-Monorepo (`pnpm-workspace.yaml`) | -| Node | **22+** | -| Frontend | **Vite 5 + React 18 + Tailwind 4 + React Router 7 + React Query 5 + Sonner** (Toasts) + **lucide-react** (Icons) | -| Backend | **Fastify 5 + tRPC v11** (`@trpc/server/adapters/fastify`) | -| ORM | **Drizzle 0.38** + **drizzle-kit** | -| Datenbank | **Postgres 16** | -| Auth | **Authentik OIDC** via `openid-client v6` (Auth Code + PKCE), Session-Cookies HttpOnly+SameSite=Lax, Sessions in Postgres | -| Validation | **Zod 3** (geteilt zwischen FE und BE via `@cockpit/shared`) | -| Logging | **pino** (Fastify-built-in) | -| Styling | Tailwind 4 + CSS-Custom-Properties · Hausschrift **Space Grotesk** (selbst gehostet) · Keycap-Button-Stil | -| Tests | **Vitest** (Unit + tRPC) | -| Reverse-Proxy | **Caddy 2** (siehe `Caddyfile`) | -| CI | **Gitea Actions** (`.gitea/workflows/ci.yml`) | -| Deployment | **Docker Compose** (`docker-compose.yml`) auf ToolCTRL-interner Infra | +| Layer | Wahl | +| ------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Sprache | **TypeScript** strict, ESM überall | +| Repo-Layout | **pnpm**-Monorepo (`pnpm-workspace.yaml`) | +| Node | **22+** | +| Frontend | **Vite 5 + React 18 + Tailwind 4 + React Router 7 + React Query 5 + Sonner** (Toasts) + **lucide-react** (Icons) | +| Backend | **Fastify 5 + tRPC v11** (`@trpc/server/adapters/fastify`) | +| ORM | **Drizzle 0.38** + **drizzle-kit** | +| Datenbank | **Postgres 16** | +| Auth | **Authentik OIDC** via `openid-client v6` (Auth Code + PKCE), Session-Cookies HttpOnly+SameSite=Lax, Sessions in Postgres | +| Validation | **Zod 3** (geteilt zwischen FE und BE via `@cockpit/shared`) | +| Logging | **pino** (Fastify-built-in) | +| Styling | Tailwind 4 + CSS-Custom-Properties · Hausschrift **Space Grotesk** (selbst gehostet) · Keycap-Button-Stil | +| Tests | **Vitest** (Unit + tRPC) | +| Reverse-Proxy | **Caddy 2** (siehe `Caddyfile`) | +| CI | **Gitea Actions** (`.gitea/workflows/ci.yml`) | +| Deployment | **Docker Compose** (`docker-compose.yml`) auf ToolCTRL-interner Infra | ⚠️ **Namespace-Hinweis**: Die internen pnpm-Packages heißen `@cockpit/*` (Historie), die App selbst heißt **everythingbutwork**. Nicht umbenennen — das würde unzählige Imports brechen ohne Mehrwert. @@ -153,11 +153,11 @@ Im Dev-Modus läuft ein **Auto-Login** als Admin-User `dejan@tool-ctrl.de`. Auth ### Wichtige Ports -| Service | Port | Quelle | -|---|---|---| -| Vite Dev-Server | **7173** | `apps/web/vite.config.ts` | -| Fastify API | **7100** | `.env` (`PORT`) | -| Postgres (Dev) | **55432** | `docker-compose.dev.yml` (5432 würde mit Mac-System-Postgres kollidieren) | +| Service | Port | Quelle | +| --------------- | --------- | ------------------------------------------------------------------------- | +| Vite Dev-Server | **7173** | `apps/web/vite.config.ts` | +| Fastify API | **7100** | `.env` (`PORT`) | +| Postgres (Dev) | **55432** | `docker-compose.dev.yml` (5432 würde mit Mac-System-Postgres kollidieren) | ### Häufig genutzte Scripts @@ -178,29 +178,29 @@ Postgres 16, Drizzle als ORM und Migrator. **Schema-Single-Source-of-Truth**: `p ### Tabellen (aktueller Stand nach Migration 0011) -| Tabelle | Zweck | -|---|---| -| `user` | Mitarbeiter (`authentik_sub` als Identitäts-Anker), `is_admin`-Flag | -| `session` | Server-Side Sessions (Postgres-persistent), Browser kennt nur Cookie-`id` | -| `audit_event` | Audit-Trail (Tabelle vorhanden; `withAudit`-Wrapper aktuell nicht verdrahtet) | -| `area` | Bereich (entwicklung / vertrieb / marketing / allgemein) | -| `project` | Projekt: Name, `description`, `maxime`, `ziel`/`nicht_ziele`/`liefergegenstaende`, `zieldreieck_flex`, `modus`, `phase`, Outline-/Plane-URLs, Arbeitsmodus, `pflicht_minimum_state` JSONB, Status-/Trend-Cache, `abschlussvermerk`, Archiv-Felder | -| `milestone` | Cockpit-Sicht-Meilenstein pro Projekt (+ `ergebnis`, `kundennutzen`) | -| `project_status` | Statusbericht-Historie pro Projekt (Ampel/Trend/Text, extern-Flag) | -| `phase_gate` | Gate-Review am Phasenende (Ergebnis erreicht? + Notiz) | -| `change_entry` | Änderungs-Log pro Projekt (Scope-Creep) | -| `stakeholder` | Stakeholder-Analyse pro Projekt (Interesse × Einfluss, Haltung) | -| `risk` | Risiko- & Chancen-Register pro Projekt (W×A, Showstopper, Owner, Strategie) | -| `day_entry` | Tageseintrag (begun / full / absent), `published_at` für Vortrags-Veröffentlichung | -| `todo` | Tagesplan-Item, optional an Bezug gebunden, `carried_over`-Flag | -| `absence_day` | Urlaub/Krank/Sonstiges — zählt nicht gegen Streak | -| `blocker` | Bereichsübergreifendes Hindernis | -| `lead` | Vertriebs-Lead-Dossier; optionale `project_id`-Verknüpfung zum (universellen) Projekt | -| `lead_contact` | Kontaktperson(en) eines Leads | -| `lead_touchpoint` | Austausch-Log eines Leads (Kanal, Outcome, Follow-up) | -| `deal` | Pipeline-Deal; wird per Lead→Deal-Promotion erzeugt | -| `gtm_step`, `campaign`, `lead_handover` | Im Schema vorhanden (Phase-2-Vorbereitung), kein UI | -| `time_entry` | Zeiterfassungs-Block, optional an ToDo gebunden, Partial-Unique-Index erzwingt max. einen aktiven Timer pro User | +| Tabelle | Zweck | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `user` | Mitarbeiter (`authentik_sub` als Identitäts-Anker), `is_admin`-Flag | +| `session` | Server-Side Sessions (Postgres-persistent), Browser kennt nur Cookie-`id` | +| `audit_event` | Audit-Trail (Tabelle vorhanden; `withAudit`-Wrapper aktuell nicht verdrahtet) | +| `area` | Bereich (entwicklung / vertrieb / marketing / allgemein) | +| `project` | Projekt: Name, `description`, `maxime`, `ziel`/`nicht_ziele`/`liefergegenstaende`, `zieldreieck_flex`, `modus`, `phase`, Outline-/Plane-URLs, Arbeitsmodus, `pflicht_minimum_state` JSONB, Status-/Trend-Cache, `abschlussvermerk`, Archiv-Felder | +| `milestone` | Cockpit-Sicht-Meilenstein pro Projekt (+ `ergebnis`, `kundennutzen`) | +| `project_status` | Statusbericht-Historie pro Projekt (Ampel/Trend/Text, extern-Flag) | +| `phase_gate` | Gate-Review am Phasenende (Ergebnis erreicht? + Notiz) | +| `change_entry` | Änderungs-Log pro Projekt (Scope-Creep) | +| `stakeholder` | Stakeholder-Analyse pro Projekt (Interesse × Einfluss, Haltung) | +| `risk` | Risiko- & Chancen-Register pro Projekt (W×A, Showstopper, Owner, Strategie) | +| `day_entry` | Tageseintrag (begun / full / absent), `published_at` für Vortrags-Veröffentlichung | +| `todo` | Tagesplan-Item, optional an Bezug gebunden, `carried_over`-Flag | +| `absence_day` | Urlaub/Krank/Sonstiges — zählt nicht gegen Streak | +| `blocker` | Bereichsübergreifendes Hindernis | +| `lead` | Vertriebs-Lead-Dossier; optionale `project_id`-Verknüpfung zum (universellen) Projekt | +| `lead_contact` | Kontaktperson(en) eines Leads | +| `lead_touchpoint` | Austausch-Log eines Leads (Kanal, Outcome, Follow-up) | +| `deal` | Pipeline-Deal; wird per Lead→Deal-Promotion erzeugt | +| `gtm_step`, `campaign`, `lead_handover` | Im Schema vorhanden (Phase-2-Vorbereitung), kein UI | +| `time_entry` | Zeiterfassungs-Block, optional an ToDo gebunden, Partial-Unique-Index erzwingt max. einen aktiven Timer pro User | Migrationen liegen in `packages/db/migrations/*.sql` und werden über `drizzle-kit migrate` angewendet. Schema-Änderungen brauchen **immer eine neue Migration** — kein Schema-Drift. @@ -210,16 +210,16 @@ Migrationen liegen in `packages/db/migrations/*.sql` und werden über `drizzle-k ### REST-Routen (Fastify) -| Methode | Pfad | Zweck | -|---|---|---| -| `GET` | `/api/health` | DB- und Authentik-Reachability-Check | -| `GET` | `/api/me` | Eingeloggter User (oder 401) | -| `GET` | `/api/auth/login` | Startet OIDC-Flow (Redirect zu Authentik) | -| `GET` | `/api/auth/callback` | Code-Exchange + Session-Create | -| `POST` | `/api/auth/logout` | Session löschen + Cookie clearen | -| `GET\|POST` | `/api/auth/dev-login` | **Nur Dev-Modus**: Auto-Login als Dejan | -| `POST` | `/trpc/` | tRPC-Endpunkt (Batch + Stream) | -| `POST` | `/internal/cron/` | Bearer-Token-geschützt: publish-day-entries, recompute-streaks, recompute-pflicht-minimum-5, pg-dump-backup | +| Methode | Pfad | Zweck | +| ----------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/health` | DB- und Authentik-Reachability-Check | +| `GET` | `/api/me` | Eingeloggter User (oder 401) | +| `GET` | `/api/auth/login` | Startet OIDC-Flow (Redirect zu Authentik) | +| `GET` | `/api/auth/callback` | Code-Exchange + Session-Create | +| `POST` | `/api/auth/logout` | Session löschen + Cookie clearen | +| `GET\|POST` | `/api/auth/dev-login` | **Nur Dev-Modus**: Auto-Login als Dejan | +| `POST` | `/trpc/` | tRPC-Endpunkt (Batch + Stream) | +| `POST` | `/internal/cron/` | Bearer-Token-geschützt: publish-day-entries, recompute-streaks, recompute-pflicht-minimum-5, pg-dump-backup | ### tRPC-Router (`apps/api/src/routers/_app.ts`) @@ -359,12 +359,12 @@ Konstanten (`MAX_TODO_TEXT_LENGTH`, `WERKTAGE`, `JOURFIXE_DAY`, `REFLEXION_REMIN `docker-compose.yml` definiert vier Services: -| Service | Image | Rolle | -|---|---|---| -| `postgres` | `postgres:16-alpine` | DB, persistentes Volume `cockpit_postgres_data`, Backups in `cockpit_backups` | -| `api` | Build aus `apps/api/Dockerfile` | Fastify auf 3000, hängt an Postgres-Health | -| `web` | Build aus `apps/web/Dockerfile` | Vite-Build wird in `cockpit_web_dist`-Volume gelegt | -| `caddy` | `caddy:2-alpine` | Reverse-Proxy (80+443), TLS via Let's Encrypt, Caddyfile mountet | +| Service | Image | Rolle | +| ---------- | ------------------------------- | ----------------------------------------------------------------------------- | +| `postgres` | `postgres:16-alpine` | DB, persistentes Volume `cockpit_postgres_data`, Backups in `cockpit_backups` | +| `api` | Build aus `apps/api/Dockerfile` | Fastify auf 3000, hängt an Postgres-Health | +| `web` | Build aus `apps/web/Dockerfile` | Vite-Build wird in `cockpit_web_dist`-Volume gelegt | +| `caddy` | `caddy:2-alpine` | Reverse-Proxy (80+443), TLS via Let's Encrypt, Caddyfile mountet | Env-Variablen werden über `.env.production` injiziert. Required: `POSTGRES_PASSWORD`, `SESSION_COOKIE_SECRET` (mind. 32 Z.), `CRON_BEARER_TOKEN`, `AUTHENTIK_*`. @@ -392,6 +392,7 @@ build → install, web build, api build (needs quality) `.gitea/workflows/deploy.yml` (Runner `runs-on: host` auf dem Homeserver): **bei jedem Push auf `main`** → `git reset --hard origin/main`, `docker compose up -d --build`, dann Warten bis `cockpit-api` healthy. Es gibt **keinen manuellen Trigger** — ein (ggf. leerer) Push löst den Deploy aus. **Startup-Verhalten der API** (`apps/api/src/server.ts`): vor dem Listen wird + 1. `runMigrations()` ausgeführt (Drizzle-Migrationen idempotent; schlägt eine fehl, startet die API nicht), dann 2. `ensureBaseAreas()` (legt die 4 Basis-Bereiche idempotent an, damit z. B. der „Neues Projekt"-Dropdown nie leer ist — auch auf einer nicht geseedeten Prod-DB). @@ -434,12 +435,12 @@ Vollständige PRD/Architektur/UX/Stories im `_bmad-output/planning-artifacts/`-O - Ursprüngliche User Journeys - Anti-Ziele und Roadmap-Hinweise -| Datei | Inhalt | -|---|---| -| `prds/prd-projectmanagement-2026-05-25/prd.md` | PRD (49 FRs, 16 NFRs) | -| `architecture.md` | Stack-Entscheidungen, Patterns | -| `ux-design-specification.md` | Brand-Tokens, Komponenten, Voice & Tone | -| `epics.md` | 6 Epics, 32 Stories | +| Datei | Inhalt | +| ---------------------------------------------- | --------------------------------------- | +| `prds/prd-projectmanagement-2026-05-25/prd.md` | PRD (49 FRs, 16 NFRs) | +| `architecture.md` | Stack-Entscheidungen, Patterns | +| `ux-design-specification.md` | Brand-Tokens, Komponenten, Voice & Tone | +| `epics.md` | 6 Epics, 32 Stories | --- diff --git a/apps/api/src/auth/routes.ts b/apps/api/src/auth/routes.ts index 0c6b16f..286ef40 100644 --- a/apps/api/src/auth/routes.ts +++ b/apps/api/src/auth/routes.ts @@ -111,13 +111,18 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise { // 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 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 found = ( + await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1) + )[0]; const userRow = found ? found : await upsertUserFromOidc({ diff --git a/apps/api/src/lib/kimai.ts b/apps/api/src/lib/kimai.ts index 134e918..431802e 100644 --- a/apps/api/src/lib/kimai.ts +++ b/apps/api/src/lib/kimai.ts @@ -47,7 +47,10 @@ async function safeText(res: Response): Promise { * Startet ein laufendes Timesheet in Kimai (Clock-In). Liefert die Kimai- * Timesheet-ID zurück oder null (nicht konfiguriert / Fehler). */ -export async function kimaiClockIn(opts: { begin: Date; description?: string }): Promise { +export async function kimaiClockIn(opts: { + begin: Date; + description?: string; +}): Promise { const cfg = kimaiConfig(); if (!cfg) { logger.debug('kimai not configured — clock-in skipped'); diff --git a/apps/api/src/routers/change.ts b/apps/api/src/routers/change.ts index 3ac83a2..a1124fa 100644 --- a/apps/api/src/routers/change.ts +++ b/apps/api/src/routers/change.ts @@ -41,7 +41,8 @@ export const changeRouter = router({ const existing = ( await ctx.db.select().from(changeEntry).where(eq(changeEntry.id, id)).limit(1) )[0]; - if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); + if (!existing) + throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); return existing; } const [updated] = await ctx.db diff --git a/apps/api/src/routers/dashboard.ts b/apps/api/src/routers/dashboard.ts index 95d47c5..8d3a832 100644 --- a/apps/api/src/routers/dashboard.ts +++ b/apps/api/src/routers/dashboard.ts @@ -1,5 +1,15 @@ import { and, eq, ne, isNull, isNotNull, lt, count, asc, desc } from 'drizzle-orm'; -import { area, project, blocker, dayEntry, user, risk, milestone, type Area, type Project } from '@cockpit/db'; +import { + area, + project, + blocker, + dayEntry, + user, + risk, + milestone, + 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'; @@ -17,73 +27,73 @@ export const dashboardRouter = router({ showstopperRows, milestoneRows, ] = 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)) - // „Gestern im Team": volle Reflexionen vor heute (heutige bleiben privat - // bis morgen). Unabhängig vom publish-Cron. - .where(and(lt(dayEntry.date, todayDate), eq(dayEntry.status, 'full'))) - .orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt)) - .limit(50), - ctx.db - .select({ - id: risk.id, - title: risk.title, - projectId: risk.projectId, - projectName: project.name, - }) - .from(risk) - .innerJoin(project, eq(project.id, risk.projectId)) - .where( - and( - eq(risk.showstopper, true), - eq(risk.kind, 'risiko'), - ne(risk.status, 'geschlossen'), - eq(project.archiviert, false), - ), + 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)) + // „Gestern im Team": volle Reflexionen vor heute (heutige bleiben privat + // bis morgen). Unabhängig vom publish-Cron. + .where(and(lt(dayEntry.date, todayDate), eq(dayEntry.status, 'full'))) + .orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt)) + .limit(50), + ctx.db + .select({ + id: risk.id, + title: risk.title, + projectId: risk.projectId, + projectName: project.name, + }) + .from(risk) + .innerJoin(project, eq(project.id, risk.projectId)) + .where( + and( + eq(risk.showstopper, true), + eq(risk.kind, 'risiko'), + ne(risk.status, 'geschlossen'), + eq(project.archiviert, false), ), - // Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend. - ctx.db - .select({ - id: milestone.id, - title: milestone.title, - due: milestone.due, - status: milestone.status, - projectId: milestone.projectId, - projectName: project.name, - projectLogoUrl: project.logoUrl, - areaId: project.areaId, - }) - .from(milestone) - .innerJoin(project, eq(project.id, milestone.projectId)) - .where( - and( - ne(milestone.status, 'done'), - isNotNull(milestone.due), - eq(project.archiviert, false), - ), - ) - .orderBy(asc(milestone.due)) - .limit(15), - ]); + ), + // Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend. + ctx.db + .select({ + id: milestone.id, + title: milestone.title, + due: milestone.due, + status: milestone.status, + projectId: milestone.projectId, + projectName: project.name, + projectLogoUrl: project.logoUrl, + areaId: project.areaId, + }) + .from(milestone) + .innerJoin(project, eq(project.id, milestone.projectId)) + .where( + and( + ne(milestone.status, 'done'), + isNotNull(milestone.due), + eq(project.archiviert, false), + ), + ) + .orderBy(asc(milestone.due)) + .limit(15), + ]); const projectsByArea = new Map(); for (const p of allProjects) { diff --git a/apps/api/src/routers/deal.ts b/apps/api/src/routers/deal.ts index 22aac41..1bf1cd0 100644 --- a/apps/api/src/routers/deal.ts +++ b/apps/api/src/routers/deal.ts @@ -65,8 +65,7 @@ export const dealRouter = router({ enteredStageAt: new Date(), wonAt: input.stage === 'abschluss' ? new Date() : null, // Gewonnener Deal = voller Auftragswert (nicht mit Wahrscheinlichkeit gewichtet). - wonValueEur: - input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null, + wonValueEur: input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null, }) .where(eq(deal.id, input.dealId)) .returning(); diff --git a/apps/api/src/routers/risk.ts b/apps/api/src/routers/risk.ts index c64095a..7991110 100644 --- a/apps/api/src/routers/risk.ts +++ b/apps/api/src/routers/risk.ts @@ -15,51 +15,45 @@ export const riskRouter = router({ .orderBy(desc(risk.showstopper), desc(risk.createdAt)); }), - create: protectedProcedure - .input(RiskSchemas.createRiskInput) - .mutation(async ({ ctx, input }) => { - const [created] = await ctx.db - .insert(risk) - .values({ - projectId: input.projectId, - kind: input.kind, - title: input.title, - wahrscheinlichkeit: input.wahrscheinlichkeit, - auswirkung: input.auswirkung, - herkunft: input.herkunft ?? null, - showstopper: input.showstopper, - massnahme: input.massnahme ?? null, - strategie: input.strategie ?? null, - ownerUserId: input.ownerUserId ?? null, - status: input.status, - createdBy: ctx.user.id, - }) - .returning(); - return created; - }), + create: protectedProcedure.input(RiskSchemas.createRiskInput).mutation(async ({ ctx, input }) => { + const [created] = await ctx.db + .insert(risk) + .values({ + projectId: input.projectId, + kind: input.kind, + title: input.title, + wahrscheinlichkeit: input.wahrscheinlichkeit, + auswirkung: input.auswirkung, + herkunft: input.herkunft ?? null, + showstopper: input.showstopper, + massnahme: input.massnahme ?? null, + strategie: input.strategie ?? null, + ownerUserId: input.ownerUserId ?? null, + status: input.status, + createdBy: ctx.user.id, + }) + .returning(); + return created; + }), - update: protectedProcedure - .input(RiskSchemas.updateRiskInput) - .mutation(async ({ ctx, input }) => { - const { id, ...patch } = input; - if (Object.keys(patch).length === 0) { - const existing = (await ctx.db.select().from(risk).where(eq(risk.id, id)).limit(1))[0]; - if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); - return existing; - } - const [updated] = await ctx.db - .update(risk) - .set({ ...patch, updatedAt: new Date() }) - .where(eq(risk.id, id)) - .returning(); - if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); - return updated; - }), + update: protectedProcedure.input(RiskSchemas.updateRiskInput).mutation(async ({ ctx, input }) => { + const { id, ...patch } = input; + if (Object.keys(patch).length === 0) { + const existing = (await ctx.db.select().from(risk).where(eq(risk.id, id)).limit(1))[0]; + if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); + return existing; + } + const [updated] = await ctx.db + .update(risk) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(risk.id, id)) + .returning(); + if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' }); + return updated; + }), - remove: protectedProcedure - .input(RiskSchemas.removeRiskInput) - .mutation(async ({ ctx, input }) => { - await ctx.db.delete(risk).where(eq(risk.id, input.id)); - return { ok: true as const }; - }), + remove: protectedProcedure.input(RiskSchemas.removeRiskInput).mutation(async ({ ctx, input }) => { + await ctx.db.delete(risk).where(eq(risk.id, input.id)); + return { ok: true as const }; + }), }); diff --git a/apps/api/src/routers/stakeholder.ts b/apps/api/src/routers/stakeholder.ts index fae159b..a6fa565 100644 --- a/apps/api/src/routers/stakeholder.ts +++ b/apps/api/src/routers/stakeholder.ts @@ -50,7 +50,8 @@ export const stakeholderRouter = router({ const existing = ( await ctx.db.select().from(stakeholder).where(eq(stakeholder.id, id)).limit(1) )[0]; - if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' }); + if (!existing) + throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' }); return existing; } const [updated] = await ctx.db @@ -58,7 +59,8 @@ export const stakeholderRouter = router({ .set(patch) .where(eq(stakeholder.id, id)) .returning(); - if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' }); + if (!updated) + throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' }); return updated; }), diff --git a/apps/web/src/components/project/ProjectLogo.tsx b/apps/web/src/components/project/ProjectLogo.tsx index 9d45a35..65577bb 100644 --- a/apps/web/src/components/project/ProjectLogo.tsx +++ b/apps/web/src/components/project/ProjectLogo.tsx @@ -120,7 +120,9 @@ export function LogoUpload({ {error ? ( {error} ) : ( - Quadratisch, wird auf 256px verkleinert. + + Quadratisch, wird auf 256px verkleinert. + )} {' · getrackt '} - {formatDurationCompact(it.trackedSeconds)} + + {formatDurationCompact(it.trackedSeconds)} + )}

@@ -131,18 +133,10 @@ function ActiveLine({ } function IdleLine() { - return ( -

Aktuell kein Timer.

- ); + return

Aktuell kein Timer.

; } -function Breakdown({ - byCategory, - total, -}: { - byCategory: Record; - total: number; -}) { +function Breakdown({ byCategory, total }: { byCategory: Record; total: number }) { if (total === 0) return null; const ordered = TIME_CATEGORIES.map((c) => ({ key: c.value, diff --git a/apps/web/src/components/time/TimerPill.tsx b/apps/web/src/components/time/TimerPill.tsx index e6007a4..af5d610 100644 --- a/apps/web/src/components/time/TimerPill.tsx +++ b/apps/web/src/components/time/TimerPill.tsx @@ -243,7 +243,9 @@ function QuickPicker({