From 63c3c15a3ca7ca3cde6df91007466050fcb1a544 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 16:17:41 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(aufgaben):=20Datenmodell=20und=20Reg?= =?UTF-8?q?eln=20f=C3=BCr=20Aufgaben=20und=20Projekte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufgabe mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft; Projekt als eigene Klammer darum. Zwei Entwurfsentscheidungen: - Die Inbox ist kein eigener Zustand, sondern schlicht eine Aufgabe ohne Bereich. Einsortieren heißt: einen Bereich setzen. Ein zusätzliches Feld könnte mit der Zuordnung auseinanderlaufen. - Projekt ist ein eigenes Modell, kein Aufgabentyp. Ein Projekt hat selbst weder Aufwand noch Fälligkeit, sondern leitet beides aus dem ab, was drinsteckt. ARBEITSPAKET und MEILENSTEIN sind Typen der Aufgaben *innerhalb* eines Projekts. @@unique([quelle, quelleRef]) ist die Grundlage für die Integrationen ab M6: sie upserten darüber, statt zu doppeln. Von Hand erfasste Aufgaben tragen quelleRef = null; PostgreSQL sieht NULL-Werte als verschieden an, sie behindern sich also nicht. Verworfene Aufgaben zählen bewusst gar nicht in den Fortschritt eines Projekts — sonst sähe es fertig aus, weil die Hälfte gestrichen wurde. Fälligkeit wird in Kalendertagen in Europe/Berlin gerechnet, nicht in 24-Stunden-Schritten: was heute um 23:00 fällig ist, ist heute fällig. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 80 ++++ prisma/schema.prisma | 180 ++++++-- src/lib/aufgabe-anzeige.test.ts | 119 ++++++ src/lib/aufgabe-anzeige.ts | 96 +++++ src/lib/aufgabe.test.ts | 384 ++++++++++++++++++ src/lib/aufgabe.ts | 290 +++++++++++++ 6 files changed, 1112 insertions(+), 37 deletions(-) create mode 100644 prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql create mode 100644 src/lib/aufgabe-anzeige.test.ts create mode 100644 src/lib/aufgabe-anzeige.ts create mode 100644 src/lib/aufgabe.test.ts create mode 100644 src/lib/aufgabe.ts diff --git a/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql b/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql new file mode 100644 index 0000000..6a56ffc --- /dev/null +++ b/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql @@ -0,0 +1,80 @@ +-- CreateEnum +CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL'); + +-- CreateEnum +CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'); + +-- CreateEnum +CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP'); + +-- CreateEnum +CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'); + +-- CreateTable +CREATE TABLE "projekt" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "beschreibung" TEXT, + "status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV', + "bereichId" TEXT, + "zielAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "projekt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "aufgabe" ( + "id" TEXT NOT NULL, + "titel" TEXT NOT NULL, + "notiz" TEXT, + "typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE', + "status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN', + "prioritaet" INTEGER NOT NULL DEFAULT 3, + "aufwandMinuten" INTEGER, + "faelligAm" TIMESTAMP(3), + "erledigtAm" TIMESTAMP(3), + "bereichId" TEXT, + "projektId" TEXT, + "quelle" "Quelle" NOT NULL DEFAULT 'MANUELL', + "quelleUrl" TEXT, + "quelleRef" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "projekt_status_idx" ON "projekt"("status"); + +-- CreateIndex +CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId"); + +-- CreateIndex +CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name"); + +-- CreateIndex +CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm"); + +-- CreateIndex +CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status"); + +-- CreateIndex +CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId"); + +-- CreateIndex +CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef"); + +-- AddForeignKey +ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da39cac..38e858b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,8 +2,8 @@ // Fachbegriffe auf Deutsch, technische Felder auf Englisch. generator client { - provider = "prisma-client" - output = "../src/generated/prisma" + provider = "prisma-client" + output = "../src/generated/prisma" moduleFormat = "esm" } @@ -18,18 +18,18 @@ enum Rolle { /// Ein Mensch, der sich anmelden darf. model Benutzer { - id String @id @default(cuid()) - email String @unique - name String? + id String @id @default(cuid()) + email String @unique + name String? /// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist. - passwortHash String? - rolle Rolle @default(BENUTZER) - aktiv Boolean @default(true) - bildUrl String? - emailGeprueft DateTime? - zuletztAktiv DateTime? - erstelltAm DateTime @default(now()) - aktualisiertAm DateTime @updatedAt + passwortHash String? + rolle Rolle @default(BENUTZER) + aktiv Boolean @default(true) + bildUrl String? + emailGeprueft DateTime? + zuletztAktiv DateTime? + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt konten Konto[] passkeys Passkey[] @@ -63,19 +63,19 @@ model Konto { /// Ein registrierter Passkey (WebAuthn) eines Benutzers. model Passkey { - id String @id @default(cuid()) - benutzerId String + id String @id @default(cuid()) + benutzerId String /// Base64URL der Credential-ID. - credentialId String @unique - publicKey Bytes - counter BigInt @default(0) + credentialId String @unique + publicKey Bytes + counter BigInt @default(0) /// Kommagetrennt, z. B. "internal,hybrid". - transports String? - geraetetyp String? - gesichert Boolean @default(false) - bezeichnung String - erstelltAm DateTime @default(now()) - zuletztBenutzt DateTime? + transports String? + geraetetyp String? + gesichert Boolean @default(false) + bezeichnung String + erstelltAm DateTime @default(now()) + zuletztBenutzt DateTime? benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@ -98,12 +98,12 @@ model WebauthnChallenge { /// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster. model Bereich { - id String @id @default(cuid()) - name String @unique + id String @id @default(cuid()) + name String @unique /// Hex-Farbe inklusive Raute, z. B. "#2563eb". - farbe String - reihenfolge Int @default(0) - aktiv Boolean @default(true) + farbe String + reihenfolge Int @default(0) + aktiv Boolean @default(true) /// Tageskapazitaet in Minuten, Montag bis Sonntag. kapazitaetMo Int @default(0) @@ -121,23 +121,129 @@ model Bereich { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt + aufgaben Aufgabe[] + projekte Projekt[] + @@index([aktiv, reihenfolge]) @@map("bereich") } +enum AufgabenTyp { + AUFGABE + IDEE + ARBEITSPAKET + MEILENSTEIN + SPIEGEL +} + +enum AufgabenStatus { + OFFEN + GEPLANT + WARTET + ERLEDIGT + VERWORFEN +} + +/// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef]. +enum Quelle { + MANUELL + ZAMMAD + WARTUNGSPLANER + TELEGRAM + SPIEGEL + MAIL + API + MCP +} + +enum ProjektStatus { + AKTIV + PAUSIERT + ABGESCHLOSSEN + VERWORFEN +} + +/// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine +/// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben. +model Projekt { + id String @id @default(cuid()) + name String + beschreibung String? + status ProjektStatus @default(AKTIV) + bereichId String? + /// Wunschtermin fuer den Abschluss, rein informativ. + zielAm DateTime? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) + aufgaben Aufgabe[] + + @@unique([name]) + @@index([status]) + @@index([bereichId]) + @@map("projekt") +} + +/// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein. +/// +/// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied, +/// "einsortieren" heisst schlicht: einen Bereich setzen. +model Aufgabe { + id String @id @default(cuid()) + titel String + notiz String? + typ AufgabenTyp @default(AUFGABE) + status AufgabenStatus @default(OFFEN) + + /// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall. + prioritaet Int @default(3) + /// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt". + aufwandMinuten Int? + + faelligAm DateTime? + erledigtAm DateTime? + + bereichId String? + projektId String? + + /// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis. + quelle Quelle @default(MANUELL) + quelleUrl String? + /// Kennung beim Ursprungssystem, z. B. die Ticketnummer. + quelleRef String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) + projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) + + /// Integrationen upserten immer ueber diese beiden Felder. Von Hand + /// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte + /// als verschieden an, sie behindern sich also nicht. + @@unique([quelle, quelleRef]) + @@index([status, faelligAm]) + @@index([bereichId, status]) + @@index([projektId]) + @@index([typ, status]) + @@map("aufgabe") +} + /// Unveraenderlicher Nachweis jeder schreibenden Aktion. model AuditEintrag { - id String @id @default(cuid()) - zeitpunkt DateTime @default(now()) - benutzerId String? + id String @id @default(cuid()) + zeitpunkt DateTime @default(now()) + benutzerId String? /// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird. benutzerEmail String - aktion String - objektTyp String - objektId String? + aktion String + objektTyp String + objektId String? /// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs. - details Json? - instanz String + details Json? + instanz String benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) diff --git a/src/lib/aufgabe-anzeige.test.ts b/src/lib/aufgabe-anzeige.test.ts new file mode 100644 index 0000000..2d78313 --- /dev/null +++ b/src/lib/aufgabe-anzeige.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { faelligkeitFarbe, fuerAnzeige, type AufgabeRoh } from './aufgabe-anzeige' + +// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit. +const jetzt = new Date('2026-03-17T09:00:00Z') + +function roh(teil: Partial = {}): AufgabeRoh { + return { + id: 'a1', + titel: 'Angebot schreiben', + notiz: null, + typ: 'AUFGABE', + status: 'OFFEN', + prioritaet: 3, + aufwandMinuten: 60, + faelligAm: null, + quelle: 'MANUELL', + quelleUrl: null, + bereich: null, + projekt: null, + ...teil, + } +} + +describe('fuerAnzeige', () => { + it('lässt den Datumswert leer, wenn kein Termin gesetzt ist', () => { + const anzeige = fuerAnzeige(roh(), jetzt) + expect(anzeige.faelligWert).toBe('') + expect(anzeige.faelligDatum).toBeNull() + expect(anzeige.faelligText).toBe('ohne Termin') + expect(anzeige.ueberfaellig).toBe(false) + }) + + it('gibt den Datumswert im Format des Eingabefelds zurück', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }), + jetzt, + ) + expect(anzeige.faelligWert).toBe('2026-03-20') + }) + + it('nimmt den Berliner Kalendertag, nicht den UTC-Tag', () => { + // 23:30 UTC am 19. ist in Berlin bereits der 20. um 00:30. + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-19T23:30:00Z') }), + jetzt, + ) + expect(anzeige.faelligWert).toBe('2026-03-20') + }) + + it('markiert eine offene Aufgabe von gestern als überfällig', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-16T09:00:00Z') }), + jetzt, + ) + expect(anzeige.ueberfaellig).toBe(true) + expect(anzeige.faelligText).toBe('seit gestern überfällig') + }) + + it('markiert eine erledigte Aufgabe von gestern nicht als überfällig', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-16T09:00:00Z'), status: 'ERLEDIGT' }), + jetzt, + ) + expect(anzeige.ueberfaellig).toBe(false) + }) + + it('schreibt das Datum ausgeschrieben für den Hinweis', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }), + jetzt, + ) + expect(anzeige.faelligDatum).toBe('Fr., 20.03.2026') + }) + + it('reicht Bereich und Projekt unverändert durch', () => { + const anzeige = fuerAnzeige( + roh({ + bereich: { id: 'b1', name: 'Gewerbe', farbe: '#7c3aed' }, + projekt: { id: 'p1', name: 'Website neu' }, + }), + jetzt, + ) + expect(anzeige.bereich?.name).toBe('Gewerbe') + expect(anzeige.projekt?.name).toBe('Website neu') + }) +}) + +describe('faelligkeitFarbe', () => { + it('warnt rot bei Überfälligkeit', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-15T09:00:00Z') }), + jetzt, + ) + expect(faelligkeitFarbe(anzeige)).toBe('gefahr') + }) + + it('warnt gelb für heute und morgen', () => { + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-03-17T15:00:00Z') }), jetzt), + ), + ).toBe('warnung') + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-03-18T15:00:00Z') }), jetzt), + ), + ).toBe('warnung') + }) + + it('bleibt bei allem Weiteren unauffällig', () => { + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-04-01T09:00:00Z') }), jetzt), + ), + ).toBe('neutral') + expect(faelligkeitFarbe(fuerAnzeige(roh(), jetzt))).toBe('neutral') + }) +}) diff --git a/src/lib/aufgabe-anzeige.ts b/src/lib/aufgabe-anzeige.ts new file mode 100644 index 0000000..2403aa7 --- /dev/null +++ b/src/lib/aufgabe-anzeige.ts @@ -0,0 +1,96 @@ +import type { AufgabenStatus, AufgabenTyp, Quelle } from '@/generated/prisma/enums' +import { + faelligkeitEinordnen, + faelligkeitText, + istUeberfaellig, + type Faelligkeit, +} from '@/lib/aufgabe' +import { alsDatum, alsTagesschluessel } from '@/lib/zeit' + +/** + * Bereitet eine Aufgabe für die Anzeige auf. + * + * Reine Funktion mit dem Zeitpunkt als Parameter: Formatierung und + * Fälligkeitsrechnung passieren serverseitig in Europe/Berlin, damit im + * Browser nichts nachgerechnet wird und alle Listen gleich aussehen. + */ + +export type AufgabeRoh = { + id: string + titel: string + notiz: string | null + typ: AufgabenTyp + status: AufgabenStatus + prioritaet: number + aufwandMinuten: number | null + faelligAm: Date | null + quelle: Quelle + quelleUrl: string | null + bereich: { id: string; name: string; farbe: string } | null + projekt: { id: string; name: string } | null +} + +export type AufgabeAnzeige = { + id: string + titel: string + notiz: string | null + typ: AufgabenTyp + status: AufgabenStatus + prioritaet: number + aufwandMinuten: number | null + /** ISO-Datum für das Formularfeld, sonst leer. */ + faelligWert: string + /** "heute fällig", "seit 3 Tagen überfällig", "ohne Termin". */ + faelligText: string + /** Ausgeschriebenes Datum für den Titelhinweis, sonst null. */ + faelligDatum: string | null + faelligkeit: Faelligkeit['art'] + ueberfaellig: boolean + quelle: Quelle + quelleUrl: string | null + bereich: { id: string; name: string; farbe: string } | null + projekt: { id: string; name: string } | null +} + +/** + * ISO-Datum in Europe/Berlin, passend für ein . + * Der Wert des Feldes ist ein reines Datum ohne Zeitzone, also der Berliner + * Kalendertag — dieselbe Rechnung wie überall sonst im Planer. + */ +function alsDatumswert(zeitpunkt: Date | null): string { + return zeitpunkt ? alsTagesschluessel(zeitpunkt) : '' +} + +export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige { + const faelligkeit = faelligkeitEinordnen(aufgabe.faelligAm, jetzt) + + return { + id: aufgabe.id, + titel: aufgabe.titel, + notiz: aufgabe.notiz, + typ: aufgabe.typ, + status: aufgabe.status, + prioritaet: aufgabe.prioritaet, + aufwandMinuten: aufgabe.aufwandMinuten, + faelligWert: alsDatumswert(aufgabe.faelligAm), + faelligText: faelligkeitText(faelligkeit), + faelligDatum: aufgabe.faelligAm ? alsDatum(aufgabe.faelligAm) : null, + faelligkeit: faelligkeit.art, + ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt), + quelle: aufgabe.quelle, + quelleUrl: aufgabe.quelleUrl, + bereich: aufgabe.bereich, + projekt: aufgabe.projekt, + } +} + +/** Wie dringend die Fälligkeit aussieht — steuert die Farbe des Kennzeichens. */ +export function faelligkeitFarbe( + anzeige: AufgabeAnzeige, +): 'neutral' | 'warnung' | 'gefahr' { + if (anzeige.ueberfaellig) return 'gefahr' + if (anzeige.faelligkeit === 'heute' || anzeige.faelligkeit === 'morgen') { + return 'warnung' + } + return 'neutral' +} diff --git a/src/lib/aufgabe.test.ts b/src/lib/aufgabe.test.ts new file mode 100644 index 0000000..130d79d --- /dev/null +++ b/src/lib/aufgabe.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest' +import { + aufgabeSchema, + aufwandSumme, + faelligkeitEinordnen, + faelligkeitText, + fortschrittBerechnen, + istAbgeschlossen, + istOffen, + istUeberfaellig, + moeglicheUebergaenge, + nachDringlichkeit, + projektSchema, + schnellerfassungSchema, + uebergangErlaubt, + type SortierbareAufgabe, +} from './aufgabe' + +// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit. +const jetzt = new Date('2026-03-17T09:00:00Z') + +describe('istOffen und istAbgeschlossen', () => { + it('trennt die beiden Gruppen vollständig', () => { + for (const status of ['OFFEN', 'GEPLANT', 'WARTET'] as const) { + expect(istOffen(status)).toBe(true) + expect(istAbgeschlossen(status)).toBe(false) + } + for (const status of ['ERLEDIGT', 'VERWORFEN'] as const) { + expect(istOffen(status)).toBe(false) + expect(istAbgeschlossen(status)).toBe(true) + } + }) +}) + +describe('uebergangErlaubt', () => { + it('lässt den Wechsel auf sich selbst zu', () => { + expect(uebergangErlaubt('OFFEN', 'OFFEN')).toBe(true) + }) + + it('lässt aus offenen Zuständen alles zu', () => { + expect(uebergangErlaubt('OFFEN', 'ERLEDIGT')).toBe(true) + expect(uebergangErlaubt('GEPLANT', 'WARTET')).toBe(true) + expect(uebergangErlaubt('WARTET', 'VERWORFEN')).toBe(true) + }) + + it('führt aus abgeschlossenen Zuständen nur zurück nach OFFEN', () => { + expect(uebergangErlaubt('ERLEDIGT', 'OFFEN')).toBe(true) + expect(uebergangErlaubt('ERLEDIGT', 'GEPLANT')).toBe(false) + expect(uebergangErlaubt('VERWORFEN', 'OFFEN')).toBe(true) + expect(uebergangErlaubt('VERWORFEN', 'ERLEDIGT')).toBe(false) + }) + + it('nennt für einen abgeschlossenen Zustand genau einen Ausweg', () => { + expect(moeglicheUebergaenge('ERLEDIGT')).toEqual(['OFFEN']) + }) +}) + +describe('faelligkeitEinordnen', () => { + it('erkennt fehlenden Termin', () => { + expect(faelligkeitEinordnen(null, jetzt)).toEqual({ art: 'ohne' }) + expect(faelligkeitEinordnen(undefined, jetzt)).toEqual({ art: 'ohne' }) + }) + + it('erkennt heute, auch spät am Abend', () => { + expect(faelligkeitEinordnen(new Date('2026-03-17T22:00:00Z'), jetzt)).toEqual({ + art: 'heute', + }) + }) + + it('rechnet in Kalendertagen, nicht in 24-Stunden-Schritten', () => { + // 23:30 UTC am 17. ist in Berlin bereits der 18. um 00:30. + expect(faelligkeitEinordnen(new Date('2026-03-17T23:30:00Z'), jetzt)).toEqual({ + art: 'morgen', + }) + }) + + it('erkennt morgen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-18T09:00:00Z'), jetzt)).toEqual({ + art: 'morgen', + }) + }) + + it('fasst die nächste Woche zusammen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-22T09:00:00Z'), jetzt)).toEqual({ + art: 'diese-woche', + tage: 5, + }) + expect(faelligkeitEinordnen(new Date('2026-03-24T09:00:00Z'), jetzt)).toEqual({ + art: 'diese-woche', + tage: 7, + }) + }) + + it('schiebt alles darüber nach später', () => { + expect(faelligkeitEinordnen(new Date('2026-03-25T09:00:00Z'), jetzt)).toEqual({ + art: 'spaeter', + tage: 8, + }) + }) + + it('zählt Überfälligkeit in Tagen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-16T09:00:00Z'), jetzt)).toEqual({ + art: 'ueberfaellig', + tage: 1, + }) + expect(faelligkeitEinordnen(new Date('2026-03-10T09:00:00Z'), jetzt)).toEqual({ + art: 'ueberfaellig', + tage: 7, + }) + }) +}) + +describe('faelligkeitText', () => { + it('schreibt jeden Fall auf Deutsch', () => { + expect(faelligkeitText({ art: 'ohne' })).toBe('ohne Termin') + expect(faelligkeitText({ art: 'heute' })).toBe('heute fällig') + expect(faelligkeitText({ art: 'morgen' })).toBe('morgen fällig') + expect(faelligkeitText({ art: 'diese-woche', tage: 3 })).toBe('in 3 Tagen fällig') + expect(faelligkeitText({ art: 'spaeter', tage: 20 })).toBe('in 20 Tagen fällig') + }) + + it('sagt bei einem Tag Rückstand "seit gestern"', () => { + expect(faelligkeitText({ art: 'ueberfaellig', tage: 1 })).toBe( + 'seit gestern überfällig', + ) + expect(faelligkeitText({ art: 'ueberfaellig', tage: 4 })).toBe( + 'seit 4 Tagen überfällig', + ) + }) +}) + +describe('istUeberfaellig', () => { + it('erkennt eine offene Aufgabe von gestern', () => { + expect(istUeberfaellig(new Date('2026-03-16T09:00:00Z'), 'OFFEN', jetzt)).toBe(true) + }) + + it('zählt den heutigen Tag nicht als überfällig', () => { + expect(istUeberfaellig(new Date('2026-03-17T06:00:00Z'), 'OFFEN', jetzt)).toBe(false) + }) + + it('lässt Erledigtes und Verworfenes in Ruhe', () => { + const gestern = new Date('2026-03-16T09:00:00Z') + expect(istUeberfaellig(gestern, 'ERLEDIGT', jetzt)).toBe(false) + expect(istUeberfaellig(gestern, 'VERWORFEN', jetzt)).toBe(false) + }) + + it('ist ohne Termin nie überfällig', () => { + expect(istUeberfaellig(null, 'OFFEN', jetzt)).toBe(false) + }) +}) + +describe('nachDringlichkeit', () => { + function auf(teil: Partial): SortierbareAufgabe { + return { + status: 'OFFEN', + prioritaet: 3, + faelligAm: null, + titel: 'Titel', + ...teil, + } + } + + it('stellt Offenes vor Abgeschlossenes', () => { + expect( + nachDringlichkeit(auf({ status: 'ERLEDIGT' }), auf({ status: 'OFFEN' })), + ).toBeGreaterThan(0) + }) + + it('stellt den früheren Termin nach vorn', () => { + const frueh = auf({ faelligAm: new Date('2026-03-18T00:00:00Z') }) + const spaet = auf({ faelligAm: new Date('2026-03-20T00:00:00Z') }) + expect(nachDringlichkeit(frueh, spaet)).toBeLessThan(0) + }) + + it('stellt Terminiertes vor Unterminiertes', () => { + const mitTermin = auf({ faelligAm: new Date('2026-04-01T00:00:00Z') }) + const ohneTermin = auf({ faelligAm: null }) + expect(nachDringlichkeit(mitTermin, ohneTermin)).toBeLessThan(0) + }) + + it('entscheidet bei gleichem Termin über die Priorität', () => { + const termin = new Date('2026-03-18T00:00:00Z') + expect( + nachDringlichkeit( + auf({ faelligAm: termin, prioritaet: 1 }), + auf({ faelligAm: termin, prioritaet: 4 }), + ), + ).toBeLessThan(0) + }) + + it('sortiert zuletzt nach dem Titel, mit deutscher Sortierung', () => { + expect( + nachDringlichkeit(auf({ titel: 'Äpfel' }), auf({ titel: 'Banane' })), + ).toBeLessThan(0) + }) + + it('ergibt eine stabile Gesamtreihenfolge', () => { + const liste: SortierbareAufgabe[] = [ + auf({ titel: 'Erledigt', status: 'ERLEDIGT' }), + auf({ titel: 'Ohne Termin' }), + auf({ titel: 'Übermorgen', faelligAm: new Date('2026-03-19T00:00:00Z') }), + auf({ titel: 'Morgen', faelligAm: new Date('2026-03-18T00:00:00Z') }), + ] + expect([...liste].sort(nachDringlichkeit).map((a) => a.titel)).toEqual([ + 'Morgen', + 'Übermorgen', + 'Ohne Termin', + 'Erledigt', + ]) + }) +}) + +describe('fortschrittBerechnen', () => { + it('ist bei keiner Aufgabe null Prozent', () => { + expect(fortschrittBerechnen([])).toEqual({ + gesamt: 0, + erledigt: 0, + offen: 0, + prozent: 0, + }) + }) + + it('rechnet Erledigtes gegen die Gesamtzahl', () => { + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'ERLEDIGT' }, + { status: 'OFFEN' }, + { status: 'GEPLANT' }, + ]), + ).toEqual({ gesamt: 4, erledigt: 2, offen: 2, prozent: 50 }) + }) + + it('lässt Verworfenes ganz aus der Rechnung', () => { + // Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde. + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'VERWORFEN' }, + { status: 'OFFEN' }, + ]), + ).toEqual({ gesamt: 2, erledigt: 1, offen: 1, prozent: 50 }) + }) + + it('rundet auf ganze Prozent', () => { + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'OFFEN' }, + { status: 'OFFEN' }, + ]).prozent, + ).toBe(33) + }) +}) + +describe('aufwandSumme', () => { + it('summiert und zählt Ungeschätztes als null', () => { + expect( + aufwandSumme([ + { aufwandMinuten: 30 }, + { aufwandMinuten: null }, + { aufwandMinuten: 45 }, + ]), + ).toBe(75) + }) + + it('ist bei leerer Liste null', () => { + expect(aufwandSumme([])).toBe(0) + }) +}) + +describe('aufgabeSchema', () => { + const gueltig = { + titel: 'Angebot schreiben', + notiz: '', + typ: 'AUFGABE', + status: 'OFFEN', + prioritaet: '2', + aufwandMinuten: '90', + faelligAm: '2026-03-20', + bereichId: '', + projektId: '', + } + + it('nimmt ein vollständiges Formular an', () => { + const ergebnis = aufgabeSchema.parse(gueltig) + expect(ergebnis.titel).toBe('Angebot schreiben') + expect(ergebnis.prioritaet).toBe(2) + expect(ergebnis.aufwandMinuten).toBe(90) + expect(ergebnis.faelligAm).toBeInstanceOf(Date) + }) + + it('macht aus leeren Feldern null statt leerer Zeichenketten', () => { + const ergebnis = aufgabeSchema.parse(gueltig) + expect(ergebnis.notiz).toBeNull() + expect(ergebnis.bereichId).toBeNull() + expect(ergebnis.projektId).toBeNull() + }) + + it('lässt Aufwand und Termin weg, wenn nichts angegeben ist', () => { + const ergebnis = aufgabeSchema.parse({ + ...gueltig, + aufwandMinuten: '', + faelligAm: '', + }) + expect(ergebnis.aufwandMinuten).toBeNull() + expect(ergebnis.faelligAm).toBeNull() + }) + + it('weist einen zu kurzen Titel ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, titel: 'A' }).success).toBe(false) + }) + + it('hält die Priorität zwischen 1 und 5', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '0' }).success).toBe(false) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '6' }).success).toBe(false) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '1' }).success).toBe(true) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '5' }).success).toBe(true) + }) + + it('weist einen Aufwand über acht Stunden ab', () => { + // Genau acht Stunden sind noch erlaubt, eine Minute mehr nicht. + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '480' }).success).toBe( + true, + ) + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '481' }).success).toBe( + false, + ) + }) + + it('weist einen Aufwand von null oder weniger ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '0' }).success).toBe( + false, + ) + }) + + it('weist ein unsinniges Datum ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, faelligAm: 'übermorgen' }).success).toBe( + false, + ) + }) +}) + +describe('schnellerfassungSchema', () => { + it('kommt mit einem Titel aus', () => { + expect(schnellerfassungSchema.parse({ titel: 'Rückruf Meier' })).toEqual({ + titel: 'Rückruf Meier', + typ: 'AUFGABE', + }) + }) + + it('kennt auch Ideen', () => { + expect( + schnellerfassungSchema.parse({ titel: 'Blog aufsetzen', typ: 'IDEE' }).typ, + ).toBe('IDEE') + }) + + it('lässt keine anderen Typen zu', () => { + expect( + schnellerfassungSchema.safeParse({ titel: 'Etwas', typ: 'MEILENSTEIN' }).success, + ).toBe(false) + }) +}) + +describe('projektSchema', () => { + it('nimmt ein Projekt an', () => { + const ergebnis = projektSchema.parse({ + name: 'Website neu', + beschreibung: '', + status: 'AKTIV', + bereichId: '', + zielAm: '', + }) + expect(ergebnis.name).toBe('Website neu') + expect(ergebnis.beschreibung).toBeNull() + expect(ergebnis.zielAm).toBeNull() + }) + + it('weist einen zu kurzen Namen ab', () => { + expect( + projektSchema.safeParse({ name: 'X', beschreibung: '', status: 'AKTIV' }).success, + ).toBe(false) + }) +}) diff --git a/src/lib/aufgabe.ts b/src/lib/aufgabe.ts new file mode 100644 index 0000000..7114b77 --- /dev/null +++ b/src/lib/aufgabe.ts @@ -0,0 +1,290 @@ +import { z } from 'zod' +import type { + AufgabenStatus, + AufgabenTyp, + ProjektStatus, + Quelle, +} from '@/generated/prisma/enums' +import { tageDazwischen, tagesBeginn } from '@/lib/zeit' + +/** + * Regeln rund um Aufgaben. Reine Logik: keine Datenbank, keine Systemzeit — + * der Zeitpunkt kommt immer als Parameter herein. + */ + +export const PRIORITAET_MIN = 1 +export const PRIORITAET_MAX = 5 +/** Ab hier ist eine Aufgabe zu groß für einen Zeitblock am Stück. */ +export const AUFWAND_MAX_MINUTEN = 8 * 60 + +export const TYP_NAME: Record = { + AUFGABE: 'Aufgabe', + IDEE: 'Idee', + ARBEITSPAKET: 'Arbeitspaket', + MEILENSTEIN: 'Meilenstein', + SPIEGEL: 'Spiegel', +} + +export const STATUS_NAME: Record = { + OFFEN: 'Offen', + GEPLANT: 'Geplant', + WARTET: 'Wartet', + ERLEDIGT: 'Erledigt', + VERWORFEN: 'Verworfen', +} + +export const PROJEKT_STATUS_NAME: Record = { + AKTIV: 'Aktiv', + PAUSIERT: 'Pausiert', + ABGESCHLOSSEN: 'Abgeschlossen', + VERWORFEN: 'Verworfen', +} + +export const QUELLE_NAME: Record = { + MANUELL: 'Von Hand', + ZAMMAD: 'Zammad', + WARTUNGSPLANER: 'Wartungsplaner', + TELEGRAM: 'Telegram', + SPIEGEL: 'Spiegel', + MAIL: 'E-Mail', + API: 'API', + MCP: 'MCP', +} + +export const PRIORITAET_NAME: Record = { + 1: 'Sofort', + 2: 'Hoch', + 3: 'Normal', + 4: 'Niedrig', + 5: 'Irgendwann', +} + +/** Status, in denen eine Aufgabe noch Arbeit bedeutet. */ +export const OFFENE_STATUS: readonly AufgabenStatus[] = ['OFFEN', 'GEPLANT', 'WARTET'] + +/** Status, in denen nichts mehr zu tun ist. */ +export const ABGESCHLOSSENE_STATUS: readonly AufgabenStatus[] = ['ERLEDIGT', 'VERWORFEN'] + +export function istOffen(status: AufgabenStatus): boolean { + return OFFENE_STATUS.includes(status) +} + +export function istAbgeschlossen(status: AufgabenStatus): boolean { + return ABGESCHLOSSENE_STATUS.includes(status) +} + +/** + * Erlaubte Statuswechsel. + * + * Aus einem abgeschlossenen Zustand geht es nur zurueck nach OFFEN – wieder + * aufgenommen wird immer am Anfang, nicht mitten im Ablauf. + */ +const UEBERGAENGE: Record = { + OFFEN: ['GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], + GEPLANT: ['OFFEN', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], + WARTET: ['OFFEN', 'GEPLANT', 'ERLEDIGT', 'VERWORFEN'], + ERLEDIGT: ['OFFEN'], + VERWORFEN: ['OFFEN'], +} + +export function uebergangErlaubt(von: AufgabenStatus, nach: AufgabenStatus): boolean { + if (von === nach) return true + return UEBERGAENGE[von].includes(nach) +} + +export function moeglicheUebergaenge(von: AufgabenStatus): readonly AufgabenStatus[] { + return UEBERGAENGE[von] +} + +// --- Faelligkeit ----------------------------------------------------------- + +export type Faelligkeit = + | { art: 'ohne' } + | { art: 'ueberfaellig'; tage: number } + | { art: 'heute' } + | { art: 'morgen' } + | { art: 'diese-woche'; tage: number } + | { art: 'spaeter'; tage: number } + +/** + * Ordnet die Faelligkeit relativ zum aktuellen Tag ein. Gerechnet wird in + * Kalendertagen in Europe/Berlin, nicht in 24-Stunden-Schritten: was heute um + * 23:00 faellig ist, ist heute faellig und nicht "in 0,5 Tagen". + */ +export function faelligkeitEinordnen( + faelligAm: Date | null | undefined, + jetzt: Date, +): Faelligkeit { + if (!faelligAm) return { art: 'ohne' } + + const tage = tageDazwischen(jetzt, faelligAm) + if (tage < 0) return { art: 'ueberfaellig', tage: Math.abs(tage) } + if (tage === 0) return { art: 'heute' } + if (tage === 1) return { art: 'morgen' } + if (tage <= 7) return { art: 'diese-woche', tage } + return { art: 'spaeter', tage } +} + +/** Kurztext fuer die Oberflaeche. */ +export function faelligkeitText(faelligkeit: Faelligkeit): string { + switch (faelligkeit.art) { + case 'ohne': + return 'ohne Termin' + case 'ueberfaellig': + return faelligkeit.tage === 1 + ? 'seit gestern überfällig' + : `seit ${faelligkeit.tage} Tagen überfällig` + case 'heute': + return 'heute fällig' + case 'morgen': + return 'morgen fällig' + case 'diese-woche': + return `in ${faelligkeit.tage} Tagen fällig` + case 'spaeter': + return `in ${faelligkeit.tage} Tagen fällig` + } +} + +export function istUeberfaellig( + faelligAm: Date | null | undefined, + status: AufgabenStatus, + jetzt: Date, +): boolean { + if (!faelligAm || istAbgeschlossen(status)) return false + return tagesBeginn(faelligAm).getTime() < tagesBeginn(jetzt).getTime() +} + +// --- Sortierung ------------------------------------------------------------ + +export type SortierbareAufgabe = { + status: AufgabenStatus + prioritaet: number + faelligAm: Date | null + titel: string +} + +/** + * Reihenfolge in der Liste: erst was noch offen ist, darin das Dringendste + * zuerst. Ohne Termin heisst nicht unwichtig – solche Aufgaben landen nach + * den terminierten, aber vor den abgeschlossenen. + */ +export function nachDringlichkeit(a: SortierbareAufgabe, b: SortierbareAufgabe): number { + const aOffen = istOffen(a.status) + const bOffen = istOffen(b.status) + if (aOffen !== bOffen) return aOffen ? -1 : 1 + + const aTermin = a.faelligAm?.getTime() ?? null + const bTermin = b.faelligAm?.getTime() ?? null + if (aTermin !== bTermin) { + if (aTermin === null) return 1 + if (bTermin === null) return -1 + return aTermin - bTermin + } + + if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet + + return a.titel.localeCompare(b.titel, 'de') +} + +// --- Fortschritt eines Projekts ------------------------------------------- + +export type Fortschritt = { + gesamt: number + erledigt: number + offen: number + /** 0 bis 100, gerundet. Ohne Aufgaben null. */ + prozent: number +} + +export function fortschrittBerechnen( + aufgaben: readonly { status: AufgabenStatus }[], +): Fortschritt { + // Verworfenes zaehlt gar nicht mit – sonst sieht ein Projekt fertig aus, + // weil die Haelfte gestrichen wurde. + const zaehlende = aufgaben.filter((a) => a.status !== 'VERWORFEN') + const gesamt = zaehlende.length + const erledigt = zaehlende.filter((a) => a.status === 'ERLEDIGT').length + + return { + gesamt, + erledigt, + offen: gesamt - erledigt, + prozent: gesamt === 0 ? 0 : Math.round((erledigt / gesamt) * 100), + } +} + +/** Summe des geschaetzten Aufwands; nicht geschaetzte Aufgaben zaehlen null. */ +export function aufwandSumme( + aufgaben: readonly { aufwandMinuten: number | null }[], +): number { + return aufgaben.reduce((summe, a) => summe + (a.aufwandMinuten ?? 0), 0) +} + +// --- Eingaben -------------------------------------------------------------- + +const leerZuNull = (wert: unknown) => + typeof wert === 'string' && wert.trim() === '' ? null : wert + +export const aufgabeSchema = z.object({ + titel: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(200, 'Höchstens 200 Zeichen.'), + notiz: z.preprocess(leerZuNull, z.string().trim().max(5000).nullable().default(null)), + typ: z.enum(['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL']), + status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']), + prioritaet: z.coerce + .number() + .int() + .min(PRIORITAET_MIN, 'Priorität liegt zwischen 1 und 5.') + .max(PRIORITAET_MAX, 'Priorität liegt zwischen 1 und 5.'), + aufwandMinuten: z.preprocess( + leerZuNull, + z.coerce + .number() + .int('Bitte ganze Minuten angeben.') + .min(1, 'Mindestens eine Minute.') + .max(AUFWAND_MAX_MINUTEN, 'Mehr als acht Stunden gehört in mehrere Aufgaben.') + .nullable() + .default(null), + ), + faelligAm: z.preprocess( + leerZuNull, + z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), + ), + bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), + projektId: z.preprocess(leerZuNull, z.string().nullable().default(null)), +}) + +export type AufgabeEingabe = z.infer + +/** Schnellerfassung: nur ein Titel, alles andere bekommt Standardwerte. */ +export const schnellerfassungSchema = z.object({ + titel: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(200, 'Höchstens 200 Zeichen.'), + typ: z.enum(['AUFGABE', 'IDEE']).default('AUFGABE'), +}) + +export const projektSchema = z.object({ + name: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(80, 'Höchstens 80 Zeichen.'), + beschreibung: z.preprocess( + leerZuNull, + z.string().trim().max(2000).nullable().default(null), + ), + status: z.enum(['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN']), + bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), + zielAm: z.preprocess( + leerZuNull, + z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), + ), +}) + +export type ProjektEingabe = z.infer From b3ca1c9ce78b403f9e66f6cf0e8452c3ec5c7dc0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 16:17:43 +0200 Subject: [PATCH 02/11] feat(aufgaben): Server Actions und Abfrageschicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen, ändern, Status setzen, einsortieren, aus einer Idee eine Aufgabe machen und löschen — jede Aktion prüft die Rolle selbst und hinterlässt einen Audit-Eintrag. Statuswechsel gehen nur die Wege, die uebergangErlaubt() zulässt: Abgeschlossenes wird erst wieder geöffnet, bevor es weitergeht. Wer per Formular etwas anderes einreicht, bekommt eine Meldung statt eines stillen Zustandssprungs. Sortiert wird in der Anwendung, nicht in SQL. Die Regel ist mehrstufig und steht als geprüfte reine Funktion in src/lib — zwei Fassungen derselben Regel wären eine Fehlerquelle. Co-Authored-By: Claude Opus 5 (1M context) --- src/server/abfragen/aufgaben.ts | 144 +++++++++++ src/server/aktionen/aufgaben.ts | 422 ++++++++++++++++++++++++++++++++ src/server/aktionen/projekte.ts | 173 +++++++++++++ 3 files changed, 739 insertions(+) create mode 100644 src/server/abfragen/aufgaben.ts create mode 100644 src/server/aktionen/aufgaben.ts create mode 100644 src/server/aktionen/projekte.ts diff --git a/src/server/abfragen/aufgaben.ts b/src/server/abfragen/aufgaben.ts new file mode 100644 index 0000000..5aeb6fb --- /dev/null +++ b/src/server/abfragen/aufgaben.ts @@ -0,0 +1,144 @@ +import 'server-only' +import type { Prisma } from '@/generated/prisma/client' +import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums' +import { nachDringlichkeit } from '@/lib/aufgabe' +import { fuerAnzeige, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige' +import { tagesBeginn } from '@/lib/zeit' +import { db } from '@/server/db' +import { jetzt } from '@/server/zeitgeber' + +/** + * Abfragen rund um Aufgaben. + * + * Alle Seiten laden über diese Stelle, damit Auswahl, Sortierung und + * Aufbereitung überall gleich sind. + */ + +const AUSWAHL = { + id: true, + titel: true, + notiz: true, + typ: true, + status: true, + prioritaet: true, + aufwandMinuten: true, + faelligAm: true, + quelle: true, + quelleUrl: true, + bereich: { select: { id: true, name: true, farbe: true } }, + projekt: { select: { id: true, name: true } }, +} satisfies Prisma.AufgabeSelect + +export type AufgabenFilter = { + bereichId?: string | undefined + projektId?: string | undefined + status?: AufgabenStatus | undefined + typ?: AufgabenTyp | undefined + /** Nur Aufgaben ohne Bereich (Inbox). */ + nurInbox?: boolean | undefined + /** Nur was noch Arbeit bedeutet. */ + nurOffen?: boolean | undefined + /** Freitext über Titel und Notiz. */ + suche?: string | undefined +} + +function alsWhere(filter: AufgabenFilter): Prisma.AufgabeWhereInput { + const where: Prisma.AufgabeWhereInput = {} + + if (filter.nurInbox) where.bereichId = null + else if (filter.bereichId) where.bereichId = filter.bereichId + + if (filter.projektId) where.projektId = filter.projektId + if (filter.typ) where.typ = filter.typ + + if (filter.status) where.status = filter.status + else if (filter.nurOffen) where.status = { in: ['OFFEN', 'GEPLANT', 'WARTET'] } + + if (filter.suche) { + where.OR = [ + { titel: { contains: filter.suche, mode: 'insensitive' } }, + { notiz: { contains: filter.suche, mode: 'insensitive' } }, + ] + } + + return where +} + +/** + * Lädt Aufgaben und bringt sie in die Reihenfolge der Dringlichkeit. + * + * Sortiert wird bewusst in der Anwendung, nicht in SQL: die Regel ist + * mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann + * Titel mit deutscher Sortierung) und steht als geprüfte reine Funktion in + * src/lib. Zwei Fassungen derselben Regel wären eine Fehlerquelle. + */ +export async function aufgabenLaden( + filter: AufgabenFilter = {}, + grenze = 500, +): Promise { + const zeilen = await db.aufgabe.findMany({ + where: alsWhere(filter), + select: AUSWAHL, + orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }], + take: grenze, + }) + + const zeitpunkt = jetzt() + return zeilen + .slice() + .sort(nachDringlichkeit) + .map((zeile) => fuerAnzeige(zeile, zeitpunkt)) +} + +/** Bereiche zur Auswahl in Formularen und Filtern. */ +export async function bereicheLaden() { + return db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + select: { id: true, name: true, farbe: true }, + }) +} + +/** Projekte zur Auswahl; abgeschlossene bleiben draußen. */ +export async function projekteZurAuswahlLaden() { + return db.projekt.findMany({ + where: { status: { in: ['AKTIV', 'PAUSIERT'] } }, + orderBy: { name: 'asc' }, + select: { id: true, name: true }, + }) +} + +/** Zählstände für die Navigation und die Startseite. */ +export async function zaehlstaende(): Promise<{ + inbox: number + offen: number + ueberfaellig: number + ideen: number +}> { + const zeitpunkt = jetzt() + const heuteBeginn = tagesBeginn(zeitpunkt) + + const [inbox, offen, ueberfaellig, ideen] = await Promise.all([ + db.aufgabe.count({ + where: { + bereichId: null, + typ: { not: 'IDEE' }, + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + }, + }), + db.aufgabe.count({ + where: { typ: { not: 'IDEE' }, status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }, + }), + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + faelligAm: { lt: heuteBeginn }, + }, + }), + db.aufgabe.count({ + where: { typ: 'IDEE', status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }, + }), + ]) + + return { inbox, offen, ueberfaellig, ideen } +} diff --git a/src/server/aktionen/aufgaben.ts b/src/server/aktionen/aufgaben.ts new file mode 100644 index 0000000..2a8af5a --- /dev/null +++ b/src/server/aktionen/aufgaben.ts @@ -0,0 +1,422 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import type { AufgabenStatus } from '@/generated/prisma/enums' +import { + aufgabeSchema, + istAbgeschlossen, + schnellerfassungSchema, + uebergangErlaubt, +} from '@/lib/aufgabe' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { jetzt } from '@/server/zeitgeber' + +/** + * Server Actions für Aufgaben. + * + * Jede prüft die Rolle selbst — die Seite tut es ebenfalls, aber eine Action + * ist ein eigener Einstiegspunkt. Jede Änderung hinterlässt einen + * Audit-Eintrag. + */ + +export type Rueckmeldung = + | { ok: true; meldung: string; id?: string } + | { ok: false; fehler: string; felder?: Record } + +function feldFehler(fehler: z.ZodError): Record { + const felder: Record = {} + for (const problem of fehler.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return felder +} + +/** Prüft die Rolle und gibt bei Ablehnung eine Rückmeldung statt zu werfen. */ +async function akteurOder( + rueckgabe: (fehler: string) => Rueckmeldung, +): Promise< + { akteur: Awaited> } | { antwort: Rueckmeldung } +> { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { antwort: rueckgabe(fehler.message) } + throw fehler + } +} + +function alsFormularwerte(daten: FormData): Record { + return Object.fromEntries(daten.entries()) +} + +/** + * Prüft, dass angegebener Bereich und Projekt wirklich existieren. Ein + * erfundener Fremdschlüssel soll eine Meldung geben, keinen Datenbankfehler. + */ +async function verweiseGueltig( + bereichId: string | null, + projektId: string | null, +): Promise | null> { + const felder: Record = {} + + if (bereichId) { + const bereich = await db.bereich.findUnique({ + where: { id: bereichId }, + select: { id: true }, + }) + if (!bereich) felder.bereichId = 'Diesen Bereich gibt es nicht.' + } + if (projektId) { + const projekt = await db.projekt.findUnique({ + where: { id: projektId }, + select: { id: true }, + }) + if (!projekt) felder.projektId = 'Dieses Projekt gibt es nicht.' + } + + return Object.keys(felder).length > 0 ? felder : null +} + +// --- Schnellerfassung ------------------------------------------------------ + +/** + * Erfasst mit einem einzigen Feld. Ohne Bereich landet die Aufgabe in der + * Inbox und wird später einsortiert. + */ +export async function schnellErfassen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = schnellerfassungSchema.safeParse({ + titel: daten.get('titel'), + typ: daten.get('typ') ?? 'AUFGABE', + }) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte einen Titel angeben.', + felder: feldFehler(gelesen.error), + } + } + + const aufgabe = await db.aufgabe.create({ + data: { titel: gelesen.data.titel, typ: gelesen.data.typ }, + select: { id: true, titel: true, typ: true }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.angelegt', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: aufgabe.typ, weg: 'schnellerfassung' }, + }) + + revalidatePath('/inbox') + revalidatePath('/aufgaben') + revalidatePath('/ideen') + return { + ok: true, + id: aufgabe.id, + meldung: + gelesen.data.typ === 'IDEE' ? 'Idee notiert.' : 'Aufgabe in der Inbox abgelegt.', + } +} + +// --- Anlegen und Ändern ---------------------------------------------------- + +export async function aufgabeAnlegen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const verweisFehler = await verweiseGueltig( + gelesen.data.bereichId, + gelesen.data.projektId, + ) + if (verweisFehler) { + return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler } + } + + const aufgabe = await db.aufgabe.create({ + data: { + ...gelesen.data, + erledigtAm: istAbgeschlossen(gelesen.data.status) ? jetzt() : null, + }, + select: { id: true, titel: true }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.angelegt', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: gelesen.data.typ, status: gelesen.data.status }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, id: aufgabe.id, meldung: `„${aufgabe.titel}" angelegt.` } +} + +export async function aufgabeAendern(id: string, daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorher = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, status: true, erledigtAm: true }, + }) + if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + if (!uebergangErlaubt(vorher.status, gelesen.data.status)) { + return { + ok: false, + fehler: + 'Dieser Statuswechsel ist nicht vorgesehen. Abgeschlossenes wird zuerst wieder geöffnet.', + felder: { status: 'Nicht möglich von hier aus.' }, + } + } + + const verweisFehler = await verweiseGueltig( + gelesen.data.bereichId, + gelesen.data.projektId, + ) + if (verweisFehler) { + return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler } + } + + const wirdAbgeschlossen = + istAbgeschlossen(gelesen.data.status) && !istAbgeschlossen(vorher.status) + const wirdGeoeffnet = + !istAbgeschlossen(gelesen.data.status) && istAbgeschlossen(vorher.status) + + await db.aufgabe.update({ + where: { id }, + data: { + ...gelesen.data, + ...(wirdAbgeschlossen ? { erledigtAm: jetzt() } : {}), + ...(wirdGeoeffnet ? { erledigtAm: null } : {}), + }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.geaendert', + objektTyp: 'Aufgabe', + objektId: id, + details: { + titel: gelesen.data.titel, + statusVorher: vorher.status, + statusNachher: gelesen.data.status, + }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, id, meldung: `„${gelesen.data.titel}" gespeichert.` } +} + +// --- Kleine Handgriffe ----------------------------------------------------- + +const statusEingabe = z.object({ + id: z.string().min(1), + status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']), +}) + +/** Statuswechsel mit einem Klick, etwa das Abhaken in der Liste. */ +export async function statusSetzen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = statusEingabe.safeParse({ + id: daten.get('id'), + status: daten.get('status'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const vorher = await db.aufgabe.findUnique({ + where: { id: gelesen.data.id }, + select: { id: true, titel: true, status: true }, + }) + if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + const neu: AufgabenStatus = gelesen.data.status + if (!uebergangErlaubt(vorher.status, neu)) { + return { ok: false, fehler: 'Dieser Statuswechsel ist nicht vorgesehen.' } + } + + await db.aufgabe.update({ + where: { id: vorher.id }, + data: { + status: neu, + erledigtAm: istAbgeschlossen(neu) ? jetzt() : null, + }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.status', + objektTyp: 'Aufgabe', + objektId: vorher.id, + details: { titel: vorher.titel, vorher: vorher.status, nachher: neu }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, meldung: `„${vorher.titel}" ist jetzt ${neu.toLowerCase()}.` } +} + +const einsortierenEingabe = z.object({ + id: z.string().min(1), + bereichId: z.string().min(1), +}) + +/** Aus der Inbox in einen Bereich schieben. */ +export async function einsortieren(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = einsortierenEingabe.safeParse({ + id: daten.get('id'), + bereichId: daten.get('bereichId'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Bitte einen Bereich wählen.' } + + const [aufgabe, bereich] = await Promise.all([ + db.aufgabe.findUnique({ + where: { id: gelesen.data.id }, + select: { id: true, titel: true }, + }), + db.bereich.findUnique({ + where: { id: gelesen.data.bereichId }, + select: { id: true, name: true }, + }), + ]) + + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht.' } + + await db.aufgabe.update({ + where: { id: aufgabe.id }, + data: { bereichId: bereich.id }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.einsortiert', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, bereich: bereich.name }, + }) + + revalidatePath('/inbox') + revalidatePath('/aufgaben') + return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` } +} + +/** Aus einer Idee wird eine Aufgabe. */ +export async function ideeUebernehmen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const id = daten.get('id') + if (typeof id !== 'string' || id.length === 0) { + return { ok: false, fehler: 'Ungültige Anfrage.' } + } + + const idee = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, typ: true }, + }) + if (!idee) return { ok: false, fehler: 'Diese Idee gibt es nicht mehr.' } + if (idee.typ !== 'IDEE') { + return { ok: false, fehler: 'Das ist keine Idee.' } + } + + await db.aufgabe.update({ + where: { id: idee.id }, + data: { typ: 'AUFGABE', status: 'OFFEN', erledigtAm: null }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.aus-idee', + objektTyp: 'Aufgabe', + objektId: idee.id, + details: { titel: idee.titel }, + }) + + revalidatePath('/ideen') + revalidatePath('/aufgaben') + revalidatePath('/inbox') + return { ok: true, meldung: `„${idee.titel}" ist jetzt eine Aufgabe.` } +} + +// --- Löschen --------------------------------------------------------------- + +export async function aufgabeLoeschen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const id = daten.get('id') + if (typeof id !== 'string' || id.length === 0) { + return { ok: false, fehler: 'Ungültige Anfrage.' } + } + + const aufgabe = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, typ: true }, + }) + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + await db.aufgabe.delete({ where: { id: aufgabe.id } }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.geloescht', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: aufgabe.typ }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, meldung: `„${aufgabe.titel}" gelöscht.` } +} diff --git a/src/server/aktionen/projekte.ts b/src/server/aktionen/projekte.ts new file mode 100644 index 0000000..bb66790 --- /dev/null +++ b/src/server/aktionen/projekte.ts @@ -0,0 +1,173 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import type { z } from 'zod' +import { projektSchema } from '@/lib/aufgabe' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' + +export type Rueckmeldung = + | { ok: true; meldung: string; id?: string } + | { ok: false; fehler: string; felder?: Record } + +function feldFehler(fehler: z.ZodError): Record { + const felder: Record = {} + for (const problem of fehler.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return felder +} + +async function akteur() { + return pruefeRolle('BENUTZER') +} + +export async function projektAnlegen(daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries())) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorhanden = await db.projekt.findUnique({ + where: { name: gelesen.data.name }, + select: { id: true }, + }) + if (vorhanden) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + const projekt = await db.projekt.create({ + data: gelesen.data, + select: { id: true, name: true }, + }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.angelegt', + objektTyp: 'Projekt', + objektId: projekt.id, + details: { name: projekt.name }, + }) + + revalidatePath('/projekte') + return { ok: true, id: projekt.id, meldung: `Projekt „${projekt.name}" angelegt.` } +} + +export async function projektAendern(id: string, daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries())) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorher = await db.projekt.findUnique({ + where: { id }, + select: { id: true, name: true, status: true }, + }) + if (!vorher) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' } + + const dublette = await db.projekt.findFirst({ + where: { name: gelesen.data.name, id: { not: id } }, + select: { id: true }, + }) + if (dublette) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + await db.projekt.update({ where: { id }, data: gelesen.data }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.geaendert', + objektTyp: 'Projekt', + objektId: id, + details: { + vorher: vorher.name, + nachher: gelesen.data.name, + statusVorher: vorher.status, + statusNachher: gelesen.data.status, + }, + }) + + revalidatePath('/projekte') + revalidatePath(`/projekte/${id}`) + return { ok: true, id, meldung: `Projekt „${gelesen.data.name}" gespeichert.` } +} + +/** + * Löscht ein Projekt. Die enthaltenen Aufgaben bleiben bestehen und verlieren + * nur ihre Zuordnung — sonst verschwände mit dem Projekt auch die Arbeit. + */ +export async function projektLoeschen(daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const id = daten.get('id') + if (typeof id !== 'string' || id.length === 0) { + return { ok: false, fehler: 'Ungültige Anfrage.' } + } + + const projekt = await db.projekt.findUnique({ + where: { id }, + select: { id: true, name: true, _count: { select: { aufgaben: true } } }, + }) + if (!projekt) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' } + + await db.projekt.delete({ where: { id: projekt.id } }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.geloescht', + objektTyp: 'Projekt', + objektId: projekt.id, + details: { name: projekt.name, aufgabenGeloest: projekt._count.aufgaben }, + }) + + revalidatePath('/projekte') + revalidatePath('/aufgaben') + return { + ok: true, + meldung: + projekt._count.aufgaben > 0 + ? `Projekt „${projekt.name}" gelöscht. ${projekt._count.aufgaben} Aufgabe(n) bleiben erhalten.` + : `Projekt „${projekt.name}" gelöscht.`, + } +} From a4a889fcb8767a17dc3122375d451f5a603462c5 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 16:17:44 +0200 Subject: [PATCH 03/11] feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Schnellerfassung braucht ein Feld und einen Knopf; wer beim Erfassen erst ein Formular ausfüllen muss, erfasst nichts. Der Fokus bleibt danach im Feld, damit mehrere Sachen hintereinander hineingehen. Die Filter der Aufgabenliste stehen in der Adresse, nicht im Zustand der Komponente — so lässt sich eine gefilterte Liste als Lesezeichen ablegen, und der Zurück-Knopf tut, was er soll. Die Startseite zeigt jetzt, was überfällig, heute oder morgen fällig ist, statt nur die Bereiche aufzuzählen. Für die Auswahlfelder bewusst das native { + if (e.key === 'Enter') setzen('suche', e.currentTarget.value.trim()) + }} + /> + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + {projekte.length > 0 && ( +
+ + +
+ )} + + {gesetzt.length > 0 && ( + + )} + + ) +} diff --git a/src/app/(app)/aufgaben/neue-aufgabe.tsx b/src/app/(app)/aufgaben/neue-aufgabe.tsx new file mode 100644 index 0000000..fb4771d --- /dev/null +++ b/src/app/(app)/aufgaben/neue-aufgabe.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useState } from 'react' +import { Plus, X } from 'lucide-react' +import { AufgabeFormular } from '@/components/aufgaben/aufgabe-formular' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' + +type Eigenschaften = { + bereiche: { id: string; name: string }[] + projekte: { id: string; name: string }[] + vorgabe?: { bereichId?: string; projektId?: string; typ?: string } + beschriftung?: string +} + +/** Klappt das vollständige Formular auf; im Normalfall genügt die Inbox. */ +export function NeueAufgabe({ + bereiche, + projekte, + vorgabe, + beschriftung = 'Neue Aufgabe', +}: Eigenschaften) { + const [offen, setOffen] = useState(false) + + if (!offen) { + return ( + + ) + } + + return ( + + + {beschriftung} + + + + setOffen(false)} + /> + + + ) +} diff --git a/src/app/(app)/aufgaben/page.tsx b/src/app/(app)/aufgaben/page.tsx new file mode 100644 index 0000000..c23f478 --- /dev/null +++ b/src/app/(app)/aufgaben/page.tsx @@ -0,0 +1,110 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import { Filterleiste } from '@/app/(app)/aufgaben/filterleiste' +import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe' +import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' +import { Card, CardInhalt } from '@/components/ui/card' +import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums' +import { aufwandSumme } from '@/lib/aufgabe' +import { alsDauer } from '@/lib/zeit' +import { + aufgabenLaden, + bereicheLaden, + projekteZurAuswahlLaden, + type AufgabenFilter, +} from '@/server/abfragen/aufgaben' +import { verlangeAnmeldung } from '@/server/berechtigung' + +export const metadata: Metadata = { title: 'Aufgaben' } + +const STATUS_WERTE: readonly string[] = [ + 'OFFEN', + 'GEPLANT', + 'WARTET', + 'ERLEDIGT', + 'VERWORFEN', +] +const TYP_WERTE: readonly string[] = [ + 'AUFGABE', + 'IDEE', + 'ARBEITSPAKET', + 'MEILENSTEIN', + 'SPIEGEL', +] + +/** Übersetzt die Adresszeile in einen Filter; Unbekanntes wird ignoriert. */ +function filterAus( + parameter: Record, +): AufgabenFilter { + const einer = (name: string): string | undefined => { + const wert = parameter[name] + return typeof wert === 'string' && wert.length > 0 ? wert : undefined + } + + const bereich = einer('bereich') + const status = einer('status') + const typ = einer('typ') + + return { + ...(bereich === 'inbox' ? { nurInbox: true } : bereich ? { bereichId: bereich } : {}), + ...(einer('projekt') ? { projektId: einer('projekt') } : {}), + // Ohne Angabe zeigt die Liste nur, was noch Arbeit bedeutet. + ...(status === 'alle' + ? {} + : status && STATUS_WERTE.includes(status) + ? { status: status as AufgabenStatus } + : { nurOffen: true }), + ...(typ && TYP_WERTE.includes(typ) ? { typ: typ as AufgabenTyp } : {}), + ...(einer('suche') ? { suche: einer('suche') } : {}), + } +} + +export default async function AufgabenSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + await verlangeAnmeldung('/aufgaben') + const parameter = await searchParams + + const [aufgaben, bereiche, projekte] = await Promise.all([ + aufgabenLaden(filterAus(parameter)), + bereicheLaden(), + projekteZurAuswahlLaden(), + ]) + + const geschaetzt = aufwandSumme(aufgaben) + + return ( +
+
+
+

Aufgaben

+

+ {aufgaben.length === 0 + ? 'Nichts gefunden.' + : `${aufgaben.length} Aufgabe(n)${geschaetzt > 0 ? `, zusammen ${alsDauer(geschaetzt)} geschätzt` : ''}.`} +

+
+ +
+ + + + Filter …

} + > + +
+
+
+ + +
+ ) +} diff --git a/src/app/(app)/ideen/page.tsx b/src/app/(app)/ideen/page.tsx new file mode 100644 index 0000000..5a54781 --- /dev/null +++ b/src/app/(app)/ideen/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from 'next' +import { Lightbulb } from 'lucide-react' +import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' +import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import { + aufgabenLaden, + bereicheLaden, + projekteZurAuswahlLaden, +} from '@/server/abfragen/aufgaben' +import { verlangeAnmeldung } from '@/server/berechtigung' + +export const metadata: Metadata = { title: 'Ideen' } + +export default async function IdeenSeite() { + await verlangeAnmeldung('/ideen') + + const [ideen, bereiche, projekte] = await Promise.all([ + aufgabenLaden({ typ: 'IDEE', nurOffen: true }), + bereicheLaden(), + projekteZurAuswahlLaden(), + ]) + + return ( +
+
+

+ + Ideen +

+

+ Was irgendwann einmal lohnen könnte. Ideen haben bewusst keinen Termin und + drängen sich nicht in die Aufgabenliste — bis du eine übernimmst. +

+
+ + + + Idee notieren + + Festhalten, bevor sie weg ist. Bewerten kannst du später. + + + + + + + +
+

+ Gesammelt + {ideen.length > 0 && ( + + {ideen.length} + + )} +

+ + +
+
+ ) +} diff --git a/src/app/(app)/inbox/page.tsx b/src/app/(app)/inbox/page.tsx new file mode 100644 index 0000000..1706b8c --- /dev/null +++ b/src/app/(app)/inbox/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from 'next' +import { Inbox } from 'lucide-react' +import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' +import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import { + aufgabenLaden, + bereicheLaden, + projekteZurAuswahlLaden, +} from '@/server/abfragen/aufgaben' +import { verlangeAnmeldung } from '@/server/berechtigung' + +export const metadata: Metadata = { title: 'Inbox' } + +export default async function InboxSeite() { + await verlangeAnmeldung('/inbox') + + const [aufgaben, bereiche, projekte] = await Promise.all([ + aufgabenLaden({ nurInbox: true, nurOffen: true }), + bereicheLaden(), + projekteZurAuswahlLaden(), + ]) + + return ( +
+
+

+ + Inbox +

+

+ Alles, was noch keinem Bereich zugeordnet ist. Erst erfassen, später + einsortieren — Nachdenken beim Aufschreiben kostet nur Zeit. +

+
+ + + + Schnell erfassen + + Ein Titel genügt. Bereich, Termin und Aufwand kommen beim Einsortieren. + + + + + + + +
+

+ Noch einzusortieren + {aufgaben.length > 0 && ( + + {aufgaben.length} + + )} +

+ + +
+
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index b59e22c..07940ff 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,4 +1,5 @@ import { Kopfleiste } from '@/components/kopfleiste' +import { zaehlstaende } from '@/server/abfragen/aufgaben' import { INSTANZ_NAME } from '@/lib/instanz' import { versionAnzeige } from '@/lib/version' import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung' @@ -18,6 +19,7 @@ export default async function AngemeldetesLayout({ // Anmeldung dort weitergeht, wo man hinwollte. const benutzer = await verlangeAnmeldung(await angefragterPfad()) const instanz = aktuelleInstanz() + const zahlen = await zaehlstaende() return (
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({ email={benutzer.email} rolle={benutzer.rolle} instanzName={INSTANZ_NAME[instanz]} + inbox={zahlen.inbox} />
{children}
diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 5ca34da..87d3385 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,4 +1,6 @@ import Link from 'next/link' +import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' +import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung' import { Card, CardBeschreibung, @@ -8,6 +10,12 @@ import { } from '@/components/ui/card' import { wochenKapazitaet } from '@/lib/bereich' import { alsDatum, alsDauer } from '@/lib/zeit' +import { + aufgabenLaden, + bereicheLaden, + projekteZurAuswahlLaden, + zaehlstaende, +} from '@/server/abfragen/aufgaben' import { verlangeAnmeldung } from '@/server/berechtigung' import { db } from '@/server/db' import { jetzt } from '@/server/zeitgeber' @@ -16,10 +24,23 @@ export default async function StartSeite() { const benutzer = await verlangeAnmeldung('/') const heute = jetzt() - const bereiche = await db.bereich.findMany({ - where: { aktiv: true }, - orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], - }) + const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([ + aufgabenLaden({ nurOffen: true }, 200), + bereicheLaden(), + projekteZurAuswahlLaden(), + zaehlstaende(), + db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }), + ]) + + // Was jetzt zählt: überfällig, heute oder morgen fällig. Ideen bleiben draußen. + const dran = alleOffenen.filter( + (a) => + a.typ !== 'IDEE' && + (a.ueberfaellig || a.faelligkeit === 'heute' || a.faelligkeit === 'morgen'), + ) const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email @@ -30,10 +51,53 @@ export default async function StartSeite() {

{alsDatum(heute)}

-
-

Deine Bereiche

+ + + Schnell erfassen + + Landet in der{' '} + + Inbox + {' '} + und wird später einsortiert. + + + + + + - {bereiche.length === 0 ? ( +
+
+

Jetzt dran

+ + Alle {zahlen.offen} offenen Aufgaben + +
+ + +
+ +
+
+

Deine Bereiche

+ + Bereiche verwalten + +
+ + {bereichsListe.length === 0 ? ( Noch keine Bereiche angelegt.{' '} @@ -44,38 +108,51 @@ export default async function StartSeite() { ) : (
    - {bereiche.map((bereich) => ( -
  • - - - - {bereich.name} - - -

    - {alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant -

    -
    -
    -
  • - ))} + {bereichsListe.map((bereich) => { + const offeneHier = alleOffenen.filter( + (a) => a.bereich?.id === bereich.id && a.typ !== 'IDEE', + ) + return ( +
  • + + + + {bereich.name} + + +

    + {offeneHier.length === 0 + ? 'Nichts offen' + : `${offeneHier.length} offen`}{' '} + · {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche +

    +
    +
    +
  • + ) + })}
)}
- - - Was als Nächstes kommt - - Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die - Wochenplanung per Ziehen und Ablegen mit M4. - - - + {zahlen.ideen > 0 && ( + + + + + {zahlen.ideen} Idee(n) warten + + + + Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst. + + + + )}
) } diff --git a/src/app/(app)/projekte/[id]/page.tsx b/src/app/(app)/projekte/[id]/page.tsx new file mode 100644 index 0000000..6808206 --- /dev/null +++ b/src/app/(app)/projekte/[id]/page.tsx @@ -0,0 +1,153 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { ArrowLeft } from 'lucide-react' +import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe' +import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular' +import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' +import { Fortschrittsbalken } from '@/components/fortschrittsbalken' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe' +import { alsDatum, alsDauer, alsTagesschluessel } from '@/lib/zeit' +import { + aufgabenLaden, + bereicheLaden, + projekteZurAuswahlLaden, +} from '@/server/abfragen/aufgaben' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + const projekt = await db.projekt.findUnique({ + where: { id }, + select: { name: true }, + }) + return { title: projekt?.name ?? 'Projekt' } +} + +export default async function ProjektSeite({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + await verlangeAnmeldung(`/projekte/${id}`) + + const projekt = await db.projekt.findUnique({ + where: { id }, + select: { + id: true, + name: true, + beschreibung: true, + status: true, + zielAm: true, + bereichId: true, + bereich: { select: { id: true, name: true, farbe: true } }, + }, + }) + if (!projekt) notFound() + + const [aufgaben, bereiche, projekte] = await Promise.all([ + // Hier bewusst ohne nurOffen: im Projekt gehört auch das Erledigte ins Bild. + aufgabenLaden({ projektId: projekt.id }), + bereicheLaden(), + projekteZurAuswahlLaden(), + ]) + + const fortschritt = fortschrittBerechnen(aufgaben) + const offenerAufwand = aufwandSumme( + aufgaben.filter((a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN'), + ) + + return ( +
+ + + Alle Projekte + + +
+
+ {projekt.bereich && ( + + )} +

{projekt.name}

+ {projekt.status !== 'AKTIV' && ( + {PROJEKT_STATUS_NAME[projekt.status]} + )} +
+ + {projekt.beschreibung && ( +

+ {projekt.beschreibung} +

+ )} + + +

+ {fortschritt.gesamt === 0 + ? 'Noch keine Aufgaben zugeordnet.' + : `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`} + {offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)} geschätzt`} + {projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`} +

+
+ +
+

Aufgaben im Projekt

+ +
+ + + + + + Projekt bearbeiten + + + + + +
+ ) +} diff --git a/src/app/(app)/projekte/page.tsx b/src/app/(app)/projekte/page.tsx new file mode 100644 index 0000000..340d6e6 --- /dev/null +++ b/src/app/(app)/projekte/page.tsx @@ -0,0 +1,115 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular' +import { Fortschrittsbalken } from '@/components/fortschrittsbalken' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe' +import { alsDatum, alsDauer } from '@/lib/zeit' +import { bereicheLaden } from '@/server/abfragen/aufgaben' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' + +export const metadata: Metadata = { title: 'Projekte' } + +export default async function ProjekteSeite() { + await verlangeAnmeldung('/projekte') + + const [projekte, bereiche] = await Promise.all([ + db.projekt.findMany({ + orderBy: [{ status: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + beschreibung: true, + status: true, + zielAm: true, + bereich: { select: { id: true, name: true, farbe: true } }, + aufgaben: { select: { status: true, aufwandMinuten: true } }, + }, + }), + bereicheLaden(), + ]) + + return ( +
+
+
+

Projekte

+

+ Eine Klammer um mehrere Aufgaben. Aufwand und Fortschritt ergeben sich aus + dem, was drinsteckt. +

+
+ +
+ + {projekte.length === 0 ? ( + + + Noch kein Projekt angelegt. + + + ) : ( +
    + {projekte.map((projekt) => { + const fortschritt = fortschrittBerechnen(projekt.aufgaben) + const offenerAufwand = aufwandSumme( + projekt.aufgaben.filter( + (a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN', + ), + ) + + return ( +
  • + + +
    + {projekt.bereich && ( + + )} + + + {projekt.name} + + + {projekt.status !== 'AKTIV' && ( + {PROJEKT_STATUS_NAME[projekt.status]} + )} +
    + {projekt.beschreibung && ( +

    + {projekt.beschreibung} +

    + )} +
    + + + +

    + {fortschritt.gesamt === 0 + ? 'Noch keine Aufgaben zugeordnet.' + : `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`} + {offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)}`} + {projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`} +

    +
    +
    +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/app/(app)/projekte/projekt-formular.tsx b/src/app/(app)/projekte/projekt-formular.tsx new file mode 100644 index 0000000..363cb22 --- /dev/null +++ b/src/app/(app)/projekte/projekt-formular.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Loader2, Plus, Trash2, X } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { PROJEKT_STATUS_NAME } from '@/lib/aufgabe' +import { + projektAendern, + projektAnlegen, + projektLoeschen, +} from '@/server/aktionen/projekte' + +export type ProjektWerte = { + id: string + name: string + beschreibung: string | null + status: 'AKTIV' | 'PAUSIERT' | 'ABGESCHLOSSEN' | 'VERWORFEN' + bereichId: string | null + zielWert: string +} + +const STATUS = ['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'] as const + +type Eigenschaften = { + projekt?: ProjektWerte + bereiche: { id: string; name: string }[] + /** Beim Anlegen: Formular erst auf Klick zeigen. */ + einklappbar?: boolean + onFertig?: () => void +} + +export function ProjektFormular({ + projekt, + bereiche, + einklappbar = false, + onFertig, +}: Eigenschaften) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [felder, setFelder] = useState>({}) + const [offen, setOffen] = useState(!einklappbar) + const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false) + + function absenden(ereignis: React.FormEvent) { + ereignis.preventDefault() + const formular = ereignis.currentTarget + const daten = new FormData(formular) + + starte(async () => { + const antwort = projekt + ? await projektAendern(projekt.id, daten) + : await projektAnlegen(daten) + + if (antwort.ok) { + setFelder({}) + toast.success(antwort.meldung) + if (!projekt) { + formular.reset() + if (einklappbar) setOffen(false) + } + router.refresh() + onFertig?.() + } else { + setFelder(antwort.felder ?? {}) + toast.error(antwort.fehler) + } + }) + } + + function loeschen() { + if (!projekt) return + if (!loeschenBestaetigt) { + setLoeschenBestaetigt(true) + return + } + const daten = new FormData() + daten.set('id', projekt.id) + starte(async () => { + const antwort = await projektLoeschen(daten) + if (antwort.ok) { + toast.success(antwort.meldung) + router.push('/projekte') + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + } + + if (einklappbar && !offen) { + return ( + + ) + } + + const kennung = projekt?.id ?? 'neu' + + const formular = ( +
+
+ + + {felder.name &&

{felder.name}

} +
+ +
+ +