Add project and todo management

This commit is contained in:
michael committed 2026-09-02 11:03:50 +02:00
1 parent 2792051a16
commit 3da436423f
12 files changed
+1865 -43

No files matched your search

+532
View File
@@ -0,0 +1,532 @@
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>
);
}
+72 -5
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react";
import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Save, Target, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
@@ -11,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
deleteSession,
@@ -231,6 +232,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}, [data?.ticket]);
const ticket = data?.ticket;
const project = data?.project ?? null;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket";
@@ -385,7 +388,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
await updateTicket(ticket.id, {
ticketNumber: editTicketNumber,
organizationId: editOrganizationId,
workType: editWorkType
workType: isProject ? "consulting" : editWorkType
});
toast.success("Ticketdaten gespeichert", {
description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
@@ -408,7 +411,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
const effectiveWorkType = ticket.work_type ?? ticketSession.workType;
const effectiveWorkType = isProject ? "consulting" : ticket.work_type ?? ticketSession.workType;
if (!effectiveOrganizationId) {
toast.error("Organisation wählen");
@@ -503,7 +506,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId,
activity: editSessionActivity,
workType: editSessionWorkType,
workType: isProject ? "consulting" : editSessionWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
@@ -670,11 +673,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="edit-ticket-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editWorkType}
disabled={isProject}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
{isProject ? <p className="text-xs text-muted-foreground">Projekte sind immer Consulting.</p> : null}
</div>
<Button type="submit" disabled={savingTicket}>
<Save className="size-4" />
@@ -684,6 +689,67 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</CardContent>
</Card>
{project ? (
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
</div>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 lg:grid-cols-[1fr_1fr_220px]">
<div className="rounded-md border bg-muted/20 p-3">
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
<div className="space-y-2 rounded-md border bg-muted/20 p-3 text-sm">
<div>
<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>
<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>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
ToDos
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={data?.todos ?? []} onChanged={load} />
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
@@ -994,12 +1060,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="session-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editSessionWorkType}
disabled={Boolean(editingSession?.recurring_billing_id)}
disabled={Boolean(editingSession?.recurring_billing_id) || isProject}
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
{isProject ? <p className="text-xs text-muted-foreground">Projekt-Sessions bleiben Consulting.</p> : null}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}>
+11 -5
View File
@@ -104,7 +104,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
@@ -163,6 +163,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true,
startedAt: now,
pausedTotalMs: 0,
@@ -333,7 +335,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds
});
toast.success("Session nachgetragen", {
description: "Der Eintrag ist in deiner Auswertung offen."
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
});
setManualSession({
...emptyManualSession(),
@@ -405,7 +407,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<CardDescription>
{selectedTimer
? selectedTimer.organizationName
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
? `${selectedTimer.isProject ? "Projekt · " : ""}${selectedTimer.projectName || selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription>
@@ -479,7 +481,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden. Organisation und Art wurden übernommen.</p>
<p className="text-xs text-muted-foreground">
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
</p>
) : null}
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
@@ -582,7 +586,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="text-muted-foreground">{finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.</p>
<p className="text-muted-foreground">
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
</p>
</div>
) : (
<div className="space-y-2">
+102
View File
@@ -0,0 +1,102 @@
import { ClipboardList } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HelpLink } from "@/components/HelpLink";
import { TodoPanel } from "@/components/TodoPanel";
import { getTodos } from "../api";
import type { TicketTodo, TodoStatus } from "../types";
type TodosPageProps = {
onNavigate: (to: string) => void;
};
export function TodosPage({ onNavigate }: TodosPageProps) {
const [todos, setTodos] = useState<TicketTodo[]>([]);
const [status, setStatus] = useState<TodoStatus | "all">("open");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const result = await getTodos(status);
setTodos(result.todos);
} catch (error) {
toast.error("ToDos konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [status]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
done: todos.filter((todo) => todo.status === "done").length,
urgent: todos.filter((todo) => todo.status === "open" && todo.priority === "urgent").length
}),
[todos]
);
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">ToDos</h2>
<p className="text-sm text-muted-foreground">Offene Aufgaben aus Projekten und normalen Tickets an einem Ort.</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">Offen</span>
<span className="font-semibold">{counts.open}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Dringend</span>
<span className="font-semibold">{counts.urgent}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Erledigt</span>
<span className="font-semibold">{counts.done}</span>
</div>
</div>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
Aufgabenliste
</CardTitle>
<CardDescription>ToDos werden im jeweiligen Ticket angelegt und hier gesammelt angezeigt.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
<div className="flex flex-wrap gap-2 pt-2">
{(["open", "all", "done"] as const).map((value) => (
<button
key={value}
type="button"
className={`rounded-md border px-3 py-1.5 text-sm ${status === value ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
onClick={() => setStatus(value)}
>
{value === "open" ? "Offen" : value === "done" ? "Erledigt" : "Alle"}
</button>
))}
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel todos={todos} showCreate={false} showTicketMeta onNavigate={onNavigate} onChanged={load} />
{!loading && todos.length === 0 ? null : null}
</CardContent>
</Card>
</div>
);
}