fix: Bug-Review-Fixes + README aktualisiert
Backend: - Vortags-Feed (dashboard.overview + dayEntry.feed) sortierte aufsteigend mit limit(50) → zeigte die ÄLTESTEN statt neuesten Einträge. Jetzt desc. - deal.updateStage: wonValueEur wurde beim Gewinnen mit der Wahrscheinlichkeit gewichtet → zu niedriger Won-Umsatz. Jetzt voller Auftragswert. - Pflicht-Minimum „Status aktuell" wurde global (letzte Reflexion irgendwo) statt pro Projekt berechnet. Jetzt je Projekt aus dem jüngsten Statusbericht. - time.listToday/teamToday nutzten fixen +02:00-Offset → im Winter (CET) um 1h verschobene Tagesgrenze. Jetzt DST-sicher via AT TIME ZONE 'Europe/Berlin'. Frontend: - lead.tsx + projekt.tsx: Datumsanzeigen nutzten UTC (toISOString) → abends Off-by-one. Jetzt Berlin-lokal formatiert. - reflexion.tsx: Submit mappte nur den initialen ToDo-Snapshot → mitten am Tag ergänzte ToDos verloren ihren Status. Jetzt über die aktuelle Liste. - leads.tsx: CSV-Zeilenzähler konnte „-1" zeigen (Math.max). - TimerPill: Label ohne Control (a11y-Lint-Error, war repo-weit rot) + unused clsx-Import behoben → pnpm lint jetzt 0 Errors. DB: seed.ts unbedingten updatedAt-Bump aller User entfernt (Debug-Rest). README: vollständig auf aktuellen Stand gebracht (Leads/CRM, CSV-Import, PM-Tiefe, Phasen/Status/Risiken/Stakeholder/Änderungen, Tabellen bis 0011, tRPC-Router, staleTime 0, Auto-Deploy-Runner + Startup-Migrationen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
19db774c4c
commit
22f79460b2
12 files changed
+110
-79
No files matched your search
@@ -10,19 +10,22 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
|
||||
|
||||
| Bereich | Feature |
|
||||
|---|---|
|
||||
| **Dashboard** (`/dashboard`) | Unternehmens-Übersicht: Bereichs-Ampelstatus, „Heute fehlt"-Liste, bereichsübergreifende Blocker, Pflicht-Minimum-Übersicht, Jourfixe-Hinweis, „Heute im Team" (ToDos), „Zeit im Team heute" (Live-Tracking), Vortags-Feed |
|
||||
| **Bereich** (`/bereich`, `/bereich/<slug>`) | Tab-Switcher über Entwicklung / Vertrieb / Marketing — pro Bereich Projekt-Liste (Kundenprojekte / Interne Projekte) mit Pflicht-Minimum-Indicator |
|
||||
| **Projekt** (`/projekt/:id`) | Detail: Verlinkungen (Outline-Steckbrief, Plane-Board, Risikoliste), Pflicht-Minimum-Status, Meilenstein-Checkliste, DoD, Projekt-Abschluss (P1) |
|
||||
| **Heute** (`/heute`) | Tagesrhythmus: Lock-in mit ToDo-Liste, Auto-Carryover offener ToDos vom Vortag, ToDo-Editor, Zeiterfassungs-Timeline mit Edit/Delete/Nachtrag |
|
||||
| **Reflexion** (`/reflexion`) | Abend-Reflexion: ToDos abhaken, Freitext, Ampel + Trend, optional Blocker. Editierbar bis 00:00 Berlin |
|
||||
| **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/<slug>`) | 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**
|
||||
|
||||
- **Zeiterfassung**: Persistente Timer-Pill in der TopNav (Live-Sekundenzähler), Quick-Picker mit ToDos + Kategorien + Freitext, retroaktiver Nachtrag-Dialog, Bindung an ToDo optional. Personenübergreifend sichtbar im Dashboard.
|
||||
- **Leads / CRM**: Qualitative Lead-Dossiers im Vertrieb, **CSV-Import** (header-basiert, Dedup über Firmenname, Adresse → Standort, Notizen → Austausch-Log), optionale **Verknüpfung zu einem Projekt** (egal welcher Bereich) → Projekte mit Leads erscheinen in der Vertriebs-Sicht, ohne redundant angelegt zu werden.
|
||||
- **Projektmanagement-Tiefe** (operationalisiert Leitfaden v3.0): Projekt-Modus (Relax/Standard/Eskalation/Taskforce), 4-Phasen-Lebenszyklus mit Gate-Reviews, Statusbericht (Ampel/Trend/Text + Verlauf), Stakeholder-Analyse mit Macht/Interesse-Matrix, Risiko- & Chancen-Register (W×A, Showstopper, Owner, Strategie), Änderungs-Log (Scope-Creep), Zieldreieck, strukturierter Abschluss.
|
||||
- **Zeiterfassung**: Persistente Timer-Pill in der TopNav (Live-Sekundenzähler), Quick-Picker mit ToDos + Kategorien + Freitext, retroaktiver Nachtrag. „Gesamt" pro Person ist **Wall-Clock** (überlappende parallele Tasks zählen einmal); pro Aufgabe wird die Summe getrennt ausgewiesen („getrackt").
|
||||
- **Blocker**: Inline in der Reflexion oder über `n`-Shortcut bzw. den globalen „Neuer Blocker"-Dialog. Auflösung nur durch den Projekt-P1.
|
||||
- **Pflicht-Minimum-Check** (Leitfaden §9): 6-Punkte-Checkliste pro Projekt, einer davon (Status-Ampel aktuell) wird stündlich per Cron aus den Reflexions-Zeitstempeln aktualisiert.
|
||||
- **Streak**: aufeinanderfolgende Werktage mit voller Reflexion. Flammen-Pill rechts oben.
|
||||
- **Pflicht-Minimum-Check** (Leitfaden §9): 6-Punkte-Checkliste pro Projekt; Punkt „Status-Ampel aktuell (≤ 7 Werktage)" wird per Cron **pro Projekt** aus dem jüngsten Statusbericht berechnet.
|
||||
- **Streak**: aufeinanderfolgende Werktage mit voller Reflexion. Wochenenden, **bundesweite Feiertage** und gemeldete Abwesenheiten sind neutral (brechen nicht). Flammen-Pill rechts oben.
|
||||
- **Keyboard-Shortcuts**: `g d/b/h/a` Navigation, `g e/v/m` direkt in Entwicklung/Vertrieb/Marketing, `n` Neuer Blocker, `?` Cheatsheet.
|
||||
|
||||
---
|
||||
@@ -66,9 +69,10 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
|
||||
│ │ │ │ └── ui/ Button, Card, KeyCaret, SkeletonCard, ToolCtrlLogo
|
||||
│ │ │ ├── hooks/ useCurrentUser
|
||||
│ │ │ ├── lib/ router, trpc, trpc-provider, voice,
|
||||
│ │ │ │ format-duration, time-categories
|
||||
│ │ │ │ format-duration, time-categories,
|
||||
│ │ │ │ lead-meta, project-meta
|
||||
│ │ │ ├── routes/ dashboard, bereich, projekt, heute, reflexion,
|
||||
│ │ │ │ archiv, login, not-found
|
||||
│ │ │ │ leads, lead, archiv, login, not-found
|
||||
│ │ │ └── styles/globals.css
|
||||
│ │ ├── index.html
|
||||
│ │ └── vite.config.ts
|
||||
@@ -78,9 +82,11 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
|
||||
│ ├── auth/ oidc, routes (login/callback/logout/dev-login),
|
||||
│ │ session, user, middleware
|
||||
│ ├── cron/routes.ts /internal/cron/* (Bearer-Token geschützt)
|
||||
│ ├── lib/ werktag (Mo-Fr Berlin), ampel, pflicht-minimum, logger
|
||||
│ ├── routers/ _app, user, area, project, deal, gtm,
|
||||
│ │ campaign, blocker, day-entry, time, dashboard
|
||||
│ ├── lib/ werktag (Mo-Fr Berlin + Feiertage), holidays,
|
||||
│ │ streak, csv, ampel, pflicht-minimum, logger
|
||||
│ ├── routers/ _app, user, area, project, deal, gtm, lead,
|
||||
│ │ risk, stakeholder, change, campaign, blocker,
|
||||
│ │ day-entry, time, dashboard
|
||||
│ ├── env.ts Zod-validierte Env-Vars
|
||||
│ ├── db.ts Singleton-Drizzle-Client
|
||||
│ ├── trpc.ts Context, protectedProcedure, withAudit
|
||||
@@ -88,11 +94,12 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
|
||||
│ └── server.ts Fastify-Bootstrap + Healthcheck
|
||||
├── packages/
|
||||
│ ├── db/ Drizzle-Schema + Migrationen
|
||||
│ │ ├── src/{schema,client,seed,index}.ts
|
||||
│ │ ├── migrations/ 0000_init … 0007_project_description
|
||||
│ │ ├── src/{schema,client,seed,index,migrate,base-data}.ts
|
||||
│ │ ├── migrations/ 0000_init … 0011_lead_project_link
|
||||
│ │ └── drizzle.config.ts
|
||||
│ └── shared/ Zod-Schemas + Konstanten + Types
|
||||
│ └── src/schemas/ day-entry, project, deal, campaign, time-entry
|
||||
│ └── src/schemas/ day-entry, project, deal, campaign, time-entry,
|
||||
│ lead, risk, stakeholder, change
|
||||
├── _bmad-output/planning-artifacts/ PRD, Architektur, UX-Spec, Epics, Decision-Log
|
||||
├── docs/operations.md Ops-Guide (Authentik-Setup, Backup, Restore)
|
||||
├── scripts/backup.sh pg_dump-Skript (im Postgres-Container)
|
||||
@@ -169,21 +176,30 @@ pnpm --filter @cockpit/db migrate:generate --name=<name> # neue Migration
|
||||
|
||||
Postgres 16, Drizzle als ORM und Migrator. **Schema-Single-Source-of-Truth**: `packages/db/src/schema.ts`.
|
||||
|
||||
### Tabellen (aktueller Stand nach Migration 0007)
|
||||
### 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 für jede mutierende Operation |
|
||||
| `audit_event` | Audit-Trail (Tabelle vorhanden; `withAudit`-Wrapper aktuell nicht verdrahtet) |
|
||||
| `area` | Bereich (entwicklung / vertrieb / marketing / allgemein) |
|
||||
| `project` | Projekt: Name, optionale `description`, Outline-/Plane-URLs, Arbeitsmodus (cycle/kanban), `pflicht_minimum_state` JSONB, Status-/Trend-Cache, Archiv-Felder |
|
||||
| `milestone` | Cockpit-Sicht-Meilenstein pro Projekt |
|
||||
| `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 |
|
||||
| `deal`, `gtm_step`, `campaign`, `lead_handover` | Im Schema vorhanden (Phase-2-Vorbereitung), **kein UI** mehr |
|
||||
| `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.
|
||||
@@ -211,14 +227,21 @@ Migrationen liegen in `packages/db/migrations/*.sql` und werden über `drizzle-k
|
||||
health publicProcedure
|
||||
user me, list, markAbsent
|
||||
area list
|
||||
project list, byAreaSlug, byId (mit area), create, archive,
|
||||
setMilestoneStatus, listArchived
|
||||
deal list, kpis, create, updateStage (kein UI, Phase 2)
|
||||
project list, byAreaSlug, byId, create, update, archive,
|
||||
setMilestoneStatus, addMilestone, updateMilestone, removeMilestone,
|
||||
setModus, setPhase, addPhaseGate, phaseGates,
|
||||
addStatus, statusHistory, listArchived
|
||||
lead list, byId, forProject, create, update, addContact, removeContact,
|
||||
addTouchpoint, removeTouchpoint, promoteToDeal, importCsv
|
||||
risk forProject, create, update, remove
|
||||
stakeholder forProject, create, update, remove
|
||||
change forProject, create, update, remove
|
||||
deal list, kpis, create, updateStage
|
||||
gtm list, create, setStatus (kein UI, Phase 2)
|
||||
campaign list, create, funnel, crosslink (kein UI, Phase 2)
|
||||
blocker listOpen, byProject, create, resolve
|
||||
dayEntry today, todayTeam, pendingCarryOver, checkIn,
|
||||
updateTodos, submitReflection, feed, todayPresence
|
||||
dayEntry today, todayTeam, pendingCarryOver, checkIn, updateTodos,
|
||||
editTodoTitle, addTodo, submitReflection, feed, todayPresence
|
||||
time now, start, stop, listToday, update, upsertManual,
|
||||
delete, teamToday
|
||||
dashboard overview
|
||||
@@ -282,7 +305,7 @@ Login-Route ist außerhalb der `AppShell` (`/login`). Alle übrigen Routen unter
|
||||
|
||||
### State
|
||||
|
||||
- **Server-State**: ausschließlich React Query via tRPC (`createTRPCReact<AppRouter>`). Konfig in `apps/web/src/lib/trpc-provider.tsx` (30 s staleTime, ein Retry, zentrales onError → Toast).
|
||||
- **Server-State**: ausschließlich React Query via tRPC (`createTRPCReact<AppRouter>`). Konfig in `apps/web/src/lib/trpc-provider.tsx` (**staleTime 0** → Daten werden bei Mount/Tab-Fokus neu geholt, kein Polling; ein Retry; zentrales onError → Toast). Mutationen invalidieren zusätzlich gezielt die gleichzeitig sichtbaren Sichten.
|
||||
- **Auth-State**: React Query `user.me` (Hook `useCurrentUser`).
|
||||
- **Lokaler UI-State**: `useState` in der Komponente. Kein globaler Store.
|
||||
|
||||
@@ -357,7 +380,7 @@ Env-Variablen werden über `.env.production` injiziert. Required: `POSTGRES_PASS
|
||||
|
||||
`scripts/backup.sh` läuft als System-Cron im Postgres-Container: tägliches `pg_dump` mit 14-Tage-Retention + Heartbeat an `/internal/cron/pg-dump-backup` für Audit.
|
||||
|
||||
### CI
|
||||
### CI & Auto-Deploy
|
||||
|
||||
`.gitea/workflows/ci.yml`:
|
||||
|
||||
@@ -366,6 +389,12 @@ quality → install, typecheck, lint, format:check, test
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Konventionen — Pflicht-Lesen vor Code-Edits
|
||||
@@ -385,7 +414,7 @@ build → install, web build, api build (needs quality)
|
||||
## Was bewusst NICHT gebaut ist (Phase 2)
|
||||
|
||||
- **Plane-/Outline-API-Sync** — Cockpit verlinkt nur, hängt sich nichts ab
|
||||
- **Kanban-Pipeline / Lead-Funnel-UI** — Schemas existieren, aber UI raus
|
||||
- **Kanban-Pipeline / Deal-Funnel-UI** — `gtm`/`campaign` ohne UI (Lead-CRM ist gebaut)
|
||||
- **Reportings / Wochen-/Monatsauswertungen** — bewusst minimal
|
||||
- **Reaktionen / Kommentare** auf Tagesupdates
|
||||
- **Mobile schreibbar** — Desktop-first, mobil nur lesbar
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
// 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.
|
||||
// wird hier zeitabhängig berechnet und im Cron aktualisiert.
|
||||
|
||||
import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm';
|
||||
import { project, dayEntry } from '@cockpit/db';
|
||||
import { werktageBetween, todayIso } from './werktag.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { project, projectStatus } from '@cockpit/db';
|
||||
import type { Db } from '@cockpit/db';
|
||||
import { werktageBetween, todayIso, formatIsoDate } from './werktag.js';
|
||||
|
||||
const MAX_AGE_WERKTAGE = 7;
|
||||
|
||||
export async function recomputePflichtMinimumStatusActual(
|
||||
db: import('@cockpit/db').Db,
|
||||
): Promise<{ updated: number }> {
|
||||
export async function recomputePflichtMinimumStatusActual(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 = (
|
||||
// Jüngsten Statusbericht DIESES Projekts heranziehen (statt global).
|
||||
const last = (
|
||||
await db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(isNotNull(dayEntry.reflectedAt)))
|
||||
.orderBy(desc(dayEntry.reflectedAt))
|
||||
.select({ createdAt: projectStatus.createdAt })
|
||||
.from(projectStatus)
|
||||
.where(eq(projectStatus.projectId, p.id))
|
||||
.orderBy(desc(projectStatus.createdAt))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false;
|
||||
const actual = last
|
||||
? werktageBetween(formatIsoDate(last.createdAt), today) <= MAX_AGE_WERKTAGE
|
||||
: false;
|
||||
|
||||
const newState = { ...p.pflichtMinimumState, status_aktuell: actual };
|
||||
if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) {
|
||||
@@ -36,6 +36,3 @@ export async function recomputePflichtMinimumStatusActual(
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
// linter
|
||||
void isNull;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ne, isNotNull, isNull, count } from 'drizzle-orm';
|
||||
import { and, eq, ne, isNotNull, isNull, count, desc } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, risk, type Area, type Project } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, previousWerktag } from '../lib/werktag.js';
|
||||
@@ -31,7 +31,7 @@ export const dashboardRouter = router({
|
||||
.from(dayEntry)
|
||||
.innerJoin(user, eq(user.id, dayEntry.userId))
|
||||
.where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full')))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.orderBy(desc(dayEntry.publishedAt))
|
||||
.limit(50),
|
||||
ctx.db
|
||||
.select({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, isNull, isNotNull, lt, asc, inArray } from 'drizzle-orm';
|
||||
import { and, eq, isNull, isNotNull, lt, asc, desc, inArray } from 'drizzle-orm';
|
||||
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
@@ -375,7 +375,7 @@ export const dayEntryRouter = router({
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(isNotNull(dayEntry.publishedAt))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.orderBy(desc(dayEntry.publishedAt))
|
||||
.limit(50);
|
||||
return entries;
|
||||
}),
|
||||
|
||||
@@ -64,10 +64,9 @@ export const dealRouter = router({
|
||||
stage: input.stage,
|
||||
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} * ${deal.probability} / 100, 0)`
|
||||
: null,
|
||||
input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null,
|
||||
})
|
||||
.where(eq(deal.id, input.dealId))
|
||||
.returning();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gte, isNull, lte, desc } from 'drizzle-orm';
|
||||
import { and, eq, isNull, desc, sql } from 'drizzle-orm';
|
||||
import { timeEntry, dayEntry, type TimeEntry } from '@cockpit/db';
|
||||
import { TimeEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
@@ -129,16 +129,14 @@ export const timeRouter = router({
|
||||
*/
|
||||
listToday: protectedProcedure.query(async ({ ctx }) => {
|
||||
const today = todayIso();
|
||||
const startOfDay = new Date(`${today}T00:00:00+02:00`);
|
||||
const startOfTomorrow = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(
|
||||
and(
|
||||
eq(timeEntry.userId, ctx.user.id),
|
||||
gte(timeEntry.startedAt, startOfDay),
|
||||
lte(timeEntry.startedAt, startOfTomorrow),
|
||||
// Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset.
|
||||
sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(timeEntry.startedAt));
|
||||
@@ -243,17 +241,14 @@ export const timeRouter = router({
|
||||
*/
|
||||
teamToday: protectedProcedure.query(async ({ ctx }) => {
|
||||
const today = todayIso();
|
||||
const startOfDay = new Date(`${today}T00:00:00+02:00`);
|
||||
const startOfTomorrow = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
||||
const { todo } = await import('@cockpit/db');
|
||||
const { inArray } = await import('drizzle-orm');
|
||||
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(
|
||||
and(gte(timeEntry.startedAt, startOfDay), lte(timeEntry.startedAt, startOfTomorrow)),
|
||||
);
|
||||
// Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset.
|
||||
.where(sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`);
|
||||
|
||||
const todoIds = Array.from(
|
||||
new Set(rows.map((r) => r.todoId).filter((x): x is string => Boolean(x))),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Square, Plus } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { formatDuration } from '../../lib/format-duration';
|
||||
@@ -174,11 +173,15 @@ function QuickPicker({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
|
||||
<label className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
|
||||
<label
|
||||
htmlFor="timer-freetext"
|
||||
className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]"
|
||||
>
|
||||
Mit freiem Text
|
||||
</label>
|
||||
<div className="mt-1 flex gap-1.5">
|
||||
<input
|
||||
id="timer-freetext"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
|
||||
@@ -61,7 +61,8 @@ const inputCls =
|
||||
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
// Berlin-Datum (nicht UTC) — sonst springt der Default abends auf morgen.
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
|
||||
}
|
||||
|
||||
export function LeadRoute() {
|
||||
|
||||
@@ -154,7 +154,8 @@ function LeadImportDialog({ onClose }: { onClose: () => void }) {
|
||||
</label>
|
||||
{fileName && !result && (
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
Bereit: {fileName} ({csv.split('\n').filter((l) => l.trim()).length - 1} Datenzeilen)
|
||||
Bereit: {fileName} ({Math.max(0, csv.split('\n').filter((l) => l.trim()).length - 1)}{' '}
|
||||
Datenzeilen)
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ type ProjectDetail = inferRouterOutputs<AppRouter>['project']['byId']['project']
|
||||
const inputCls =
|
||||
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
|
||||
|
||||
// Datum in Berlin-Zeit als YYYY-MM-DD (nicht UTC — sonst Off-by-one abends).
|
||||
function formatDay(d: string | Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date(d));
|
||||
}
|
||||
|
||||
function countSentences(text: string): number {
|
||||
return text
|
||||
.split(/[.!?]+/)
|
||||
@@ -487,7 +492,7 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
|
||||
<span>
|
||||
<span className="font-medium">{PHASE_META[g.phase as ProjectPhase].label}</span>{' '}
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">
|
||||
{new Date(g.reviewedAt).toISOString().slice(0, 10)}
|
||||
{formatDay(g.reviewedAt)}
|
||||
</span>
|
||||
{g.notiz && <span className="block text-xs text-[var(--color-ink-muted)]">{g.notiz}</span>}
|
||||
</span>
|
||||
@@ -672,7 +677,7 @@ function StatusSection({
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] text-[var(--color-ink-muted)]">extern</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-[var(--color-ink-faint)]">
|
||||
{new Date(latest.createdAt).toISOString().slice(0, 10)}
|
||||
{formatDay(latest.createdAt)}
|
||||
{latest.authorName ? ` · ${latest.authorName}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -695,7 +700,7 @@ function StatusSection({
|
||||
}[s.ampel])} />
|
||||
<span>
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">
|
||||
{new Date(s.createdAt).toISOString().slice(0, 10)}
|
||||
{formatDay(s.createdAt)}
|
||||
</span>
|
||||
{s.text && <span className="block">{s.text}</span>}
|
||||
</span>
|
||||
@@ -790,7 +795,7 @@ function ChangeRow({ projectId, change: c }: { projectId: string; change: Change
|
||||
<p className="font-medium">{c.titel}</p>
|
||||
{c.auswirkung && <p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">Auswirkung: {c.auswirkung}</p>}
|
||||
<p className="mt-1 text-xs text-[var(--color-ink-faint)]">
|
||||
{new Date(c.createdAt).toISOString().slice(0, 10)}
|
||||
{formatDay(c.createdAt)}
|
||||
{c.authorName ? ` · ${c.authorName}` : ''}
|
||||
</p>
|
||||
{c.entscheidung === 'genehmigt' && (
|
||||
|
||||
@@ -93,10 +93,12 @@ function ReflectForm({
|
||||
}
|
||||
mutation.mutate({
|
||||
dayEntryId: entry.id,
|
||||
todos: Object.entries(todoStates).map(([id, s]) => ({
|
||||
id,
|
||||
status: s.status,
|
||||
dropReason: s.reason,
|
||||
// Über die aktuelle ToDo-Liste mappen (nicht nur den initialen Snapshot),
|
||||
// damit mitten am Tag ergänzte ToDos ihren Status behalten.
|
||||
todos: todos.map((t) => ({
|
||||
id: t.id,
|
||||
status: todoStates[t.id]?.status ?? t.status,
|
||||
dropReason: todoStates[t.id]?.reason,
|
||||
})),
|
||||
reflectionText,
|
||||
ampel,
|
||||
@@ -125,7 +127,7 @@ function ReflectForm({
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{todos.map((t) => {
|
||||
const status = todoStates[t.id]?.status ?? 'open';
|
||||
const status = todoStates[t.id]?.status ?? t.status;
|
||||
return (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<EditableTodoTitle id={t.id} title={t.title} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
|
||||
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
|
||||
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createDbClient } from './client.js';
|
||||
import {
|
||||
area,
|
||||
@@ -583,7 +583,6 @@ export async function seed(connectionString: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(user).set({ updatedAt: new Date() }).where(sql`true`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user