533 lines
23 KiB
TypeScript
533 lines
23 KiB
TypeScript
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<boolean>;
|
|
};
|
|
|
|
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<ProjectSummary[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
|
|
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
|
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
|
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
|
|
const [savingProjectId, setSavingProjectId] = useState<string | null>(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 (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
|
|
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
|
|
</div>
|
|
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
|
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Projekte</span>
|
|
<span className="font-semibold">{projects.length}</span>
|
|
</div>
|
|
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Abgerechnet</span>
|
|
<span className="font-semibold">{formatMinutes(billedMinutes)}</span>
|
|
</div>
|
|
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Offene ToDos</span>
|
|
<span className="font-semibold">{openTodos}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Projekt anlegen</CardTitle>
|
|
<CardDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
<form className="space-y-4" onSubmit={submitProject}>
|
|
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_220px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-ticket-number">
|
|
Ticket
|
|
</label>
|
|
<Input
|
|
id="project-ticket-number"
|
|
placeholder="Ticket#123456"
|
|
value={form.ticketNumber}
|
|
onChange={(event) =>
|
|
setForm({
|
|
...form,
|
|
ticketNumber: event.currentTarget.value,
|
|
organizationId: "",
|
|
organizationName: null
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
|
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
|
|
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Organisation wird übernommen.</p> : null}
|
|
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Organisation</label>
|
|
<OrganizationSelect
|
|
value={form.organizationId}
|
|
selectedName={form.organizationName}
|
|
disabled={organizationLocked}
|
|
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Art</label>
|
|
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_160px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="teamspace-project">
|
|
Teamspace-Projekt
|
|
</label>
|
|
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-starts-on">
|
|
Zeitraum von
|
|
</label>
|
|
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-ends-on">
|
|
Zeitraum bis
|
|
</label>
|
|
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-budget">
|
|
Kontingent h
|
|
</label>
|
|
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 lg:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-description">
|
|
Projektbeschreibung
|
|
</label>
|
|
<Textarea id="project-description" className="min-h-24" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-goals">
|
|
Ziele
|
|
</label>
|
|
<Textarea id="project-goals" className="min-h-24" value={form.goals} onChange={(event) => setForm({ ...form, goals: event.currentTarget.value })} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={saving}>
|
|
<Save className="size-4" />
|
|
{saving ? "Speichert..." : "Projekt anlegen"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid gap-3 xl:grid-cols-2">
|
|
{projects.map((project) => {
|
|
const isEditing = editingProjectId === project.id;
|
|
const usedPercent =
|
|
project.hour_budget_minutes && project.hour_budget_minutes > 0
|
|
? Math.min(100, Math.round(((project.billed_minutes ?? 0) / project.hour_budget_minutes) * 100))
|
|
: null;
|
|
|
|
return (
|
|
<Card key={project.id} className="overflow-hidden">
|
|
<CardHeader className="border-b bg-muted/25 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<CardTitle className="truncate text-base">{project.ticket_number}</CardTitle>
|
|
<Badge variant="outline">Projekt</Badge>
|
|
</div>
|
|
<CardDescription className="truncate">{project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}</CardDescription>
|
|
</div>
|
|
<div className="flex shrink-0 gap-1">
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
|
|
<Play className="size-4" />
|
|
Session
|
|
</Button>
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
|
<Search className="size-4" />
|
|
Öffnen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 p-4">
|
|
{isEditing ? (
|
|
<div className="space-y-3">
|
|
<div className="grid gap-3 md:grid-cols-[160px_minmax(200px,1fr)_160px]">
|
|
<Input value={editForm.ticketNumber} onChange={(event) => setEditForm({ ...editForm, ticketNumber: event.currentTarget.value })} />
|
|
<OrganizationSelect
|
|
value={editForm.organizationId}
|
|
selectedName={editForm.organizationName}
|
|
onChange={(organization) => setEditForm({ ...editForm, organizationId: organization.id, organizationName: organization.name })}
|
|
required
|
|
/>
|
|
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
|
</div>
|
|
<Input
|
|
placeholder="Teamspace-Projekt"
|
|
value={editForm.teamspaceProject}
|
|
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
|
|
/>
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
|
|
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
|
|
<Input inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
|
|
</div>
|
|
<Textarea className="min-h-20" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
|
|
<Textarea className="min-h-20" value={editForm.goals} onChange={(event) => setEditForm({ ...editForm, goals: event.currentTarget.value })} />
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" onClick={() => setEditingProjectId(null)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" disabled={savingProjectId === project.id} onClick={() => void saveProject(project)}>
|
|
<Save className="size-4" />
|
|
Speichern
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
|
<div className="rounded-md border bg-background p-2">
|
|
<span className="block text-xs text-muted-foreground">Organisation</span>
|
|
<span className="line-clamp-1 font-medium">{project.organization_name ?? project.customer_name ?? "Keine Organisation"}</span>
|
|
</div>
|
|
<div className="rounded-md border bg-background p-2">
|
|
<span className="block text-xs text-muted-foreground">Zeitraum</span>
|
|
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
|
|
</div>
|
|
<div className="rounded-md border bg-background p-2">
|
|
<span className="block text-xs text-muted-foreground">Kontingent</span>
|
|
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{usedPercent !== null ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Verbrauch</span>
|
|
<span>{usedPercent}% · {formatMinutes(project.billed_minutes ?? 0)}</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div className={cn("h-full rounded-full", usedPercent >= 90 ? "bg-amber-500" : "bg-emerald-500")} style={{ width: `${usedPercent}%` }} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-3 text-sm md:grid-cols-2">
|
|
<div>
|
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
|
<FolderKanban className="size-4 text-muted-foreground" />
|
|
Beschreibung
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
|
<Target className="size-4 text-muted-foreground" />
|
|
Ziele
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
|
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<Badge variant="secondary">{project.session_count ?? 0} Session(s)</Badge>
|
|
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offene ToDos</Badge>
|
|
<span className="inline-flex items-center gap-1">
|
|
<CalendarDays className="size-3.5" />
|
|
aktualisiert {new Date(project.updated_at).toLocaleDateString("de-DE")}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditingProjectId(project.id);
|
|
setEditForm(formFromProject(project));
|
|
}}
|
|
>
|
|
Bearbeiten
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!loading && projects.length === 0 ? <p className="rounded-md border bg-muted/20 p-6 text-center text-sm text-muted-foreground">Noch keine Projekte angelegt.</p> : null}
|
|
</div>
|
|
);
|
|
}
|