import { CalendarDays, FolderKanban, Play, Save, Search, Target } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { HelpLink } from "@/components/HelpLink"; import { OrganizationSelect } from "@/components/OrganizationSelect"; import { cn } from "@/lib/utils"; import { createProject, getProjects, lookupTicket, updateProject } from "../api"; import { currentMonth, formatMinutes } from "../format"; import { ticketPattern } from "../timers"; import type { ProjectSummary, TicketMeta } from "../types"; type ProjectsPageProps = { onNavigate: (to: string) => void; onStartTimer: (ticketNumber: string) => Promise; }; type ProjectForm = { ticketNumber: string; organizationId: string; organizationName: string | null; teamspaceProject: string; description: string; goals: string; startsOn: string; endsOn: string; hourBudget: string; }; function emptyProjectForm(): ProjectForm { return { ticketNumber: "", organizationId: "", organizationName: null, teamspaceProject: "", description: "", goals: "", startsOn: "", endsOn: "", hourBudget: "" }; } function formatHoursInput(minutes?: number | null) { if (minutes === null || minutes === undefined) { return ""; } return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60); } function parseHoursInput(value: string) { const trimmed = value.trim(); if (!trimmed) { return null; } const hours = Number(trimmed.replace(",", ".")); if (!Number.isFinite(hours) || hours < 0) { return undefined; } return Math.round(hours * 60); } function formFromProject(project: ProjectSummary): ProjectForm { return { ticketNumber: project.ticket_number, organizationId: project.organization_id ?? "", organizationName: project.organization_name ?? project.customer_name ?? null, teamspaceProject: project.teamspace_project ?? "", description: project.description, goals: project.goals, startsOn: project.starts_on ?? "", endsOn: project.ends_on ?? "", hourBudget: formatHoursInput(project.hour_budget_minutes) }; } export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [form, setForm] = useState(() => emptyProjectForm()); const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle"); const [lookupResult, setLookupResult] = useState(null); const [saving, setSaving] = useState(false); const [editingProjectId, setEditingProjectId] = useState(null); const [editForm, setEditForm] = useState(() => emptyProjectForm()); const [savingProjectId, setSavingProjectId] = useState(null); async function load() { setLoading(true); try { const result = await getProjects(); setProjects(result.projects); } catch (error) { toast.error("Projekte konnten nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setLoading(false); } } useEffect(() => { void load(); }, []); useEffect(() => { const normalizedTicketNumber = form.ticketNumber.trim(); if (!ticketPattern.test(normalizedTicketNumber)) { setLookupStatus("idle"); setLookupResult(null); return; } let cancelled = false; setLookupStatus("loading"); const timeout = window.setTimeout(async () => { try { const result = await lookupTicket(normalizedTicketNumber); if (cancelled) { return; } setLookupResult(result.ticket); setLookupStatus(result.ticket ? "found" : "not-found"); if (result.ticket?.organization_id) { setForm((current) => current.ticketNumber.trim() === normalizedTicketNumber ? { ...current, organizationId: result.ticket?.organization_id ?? "", organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null } : current ); } } catch { if (!cancelled) { setLookupStatus("idle"); setLookupResult(null); } } }, 250); return () => { cancelled = true; window.clearTimeout(timeout); }; }, [form.ticketNumber]); const organizationLocked = Boolean(lookupResult?.organization_id); const openTodos = useMemo(() => projects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [projects]); const billedMinutes = useMemo(() => projects.reduce((sum, project) => sum + Number(project.billed_minutes ?? 0), 0), [projects]); async function submitProject(event: FormEvent) { event.preventDefault(); const ticketNumber = form.ticketNumber.trim(); if (!ticketPattern.test(ticketNumber)) { toast.error("Ticketnummer prüfen", { description: "Das Format muss Ticket#XXXXXX sein." }); return; } if (!form.organizationId) { toast.error("Organisation wählen", { description: "Wenn das Ticket noch keine Stammdaten hat, muss die Organisation gesetzt werden." }); return; } const hourBudgetMinutes = parseHoursInput(form.hourBudget); if (hourBudgetMinutes === undefined) { toast.error("Stundenkontingent prüfen", { description: "Bitte Stunden als Zahl eintragen, z.B. 12,5." }); return; } setSaving(true); try { const result = await createProject({ ticketNumber, organizationId: form.organizationId, teamspaceProject: form.teamspaceProject, description: form.description, goals: form.goals, startsOn: form.startsOn || null, endsOn: form.endsOn || null, hourBudgetMinutes }); toast.success("Projekt angelegt", { description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.` }); setForm(emptyProjectForm()); setLookupResult(null); setLookupStatus("idle"); await load(); } catch (error) { toast.error("Projekt konnte nicht angelegt werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setSaving(false); } } async function saveProject(project: ProjectSummary) { const hourBudgetMinutes = parseHoursInput(editForm.hourBudget); if (hourBudgetMinutes === undefined) { toast.error("Stundenkontingent prüfen"); return; } if (!editForm.organizationId) { toast.error("Organisation wählen"); return; } setSavingProjectId(project.id); try { await updateProject(project.id, { ticketNumber: editForm.ticketNumber, organizationId: editForm.organizationId, teamspaceProject: editForm.teamspaceProject, description: editForm.description, goals: editForm.goals, startsOn: editForm.startsOn || null, endsOn: editForm.endsOn || null, hourBudgetMinutes }); toast.success("Projekt gespeichert"); setEditingProjectId(null); await load(); } catch (error) { toast.error("Projekt konnte nicht gespeichert werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { setSavingProjectId(null); } } return (

Projekte

Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.

Projekte {projects.length}
Abgerechnet {formatMinutes(billedMinutes)}
Offene ToDos {openTodos}
Projekt anlegen Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.
setForm({ ...form, ticketNumber: event.currentTarget.value, organizationId: "", organizationName: null }) } required /> {form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ?

Format: Ticket#XXXXXX

: null} {lookupStatus === "loading" ?

Suche Ticket...

: null} {lookupStatus === "found" ?

Ticket gefunden. Organisation wird übernommen.

: null} {lookupStatus === "not-found" ?

Neues Ticket. Organisation bitte wählen.

: null}
setForm({ ...form, organizationId: organization.id, organizationName: organization.name })} required />
Consulting
setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
setForm({ ...form, startsOn: event.currentTarget.value })} />
setForm({ ...form, endsOn: event.currentTarget.value })} />
setForm({ ...form, hourBudget: event.currentTarget.value })} />