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

+35 -6
View File
@@ -6,8 +6,10 @@ import {
ChartNoAxesCombined,
CircleHelp,
FileText,
FolderKanban,
GripVertical,
LayoutGrid,
ListTodo,
LogOut,
Moon,
Pause,
@@ -65,11 +67,13 @@ import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage";
import { ProjectsPage } from "./views/ProjectsPage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage";
import { TodosPage } from "./views/TodosPage";
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
import type { Dispatch, DragEvent, SetStateAction } from "react";
@@ -177,6 +181,14 @@ function routeFromPath(pathWithSearch: string) {
return { page: "statistics" as const };
}
if (pathname.startsWith("/projects")) {
return { page: "projects" as const };
}
if (pathname.startsWith("/todos")) {
return { page: "todos" as const };
}
if (pathname.startsWith("/faq")) {
return { page: "faq" as const };
}
@@ -387,11 +399,15 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
}}
>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
{ticket.is_project ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">Projekt</span> : null}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
</span>
<span className="truncate text-xs text-muted-foreground">
{ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)}
{ticket.project_name || ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s)
{ticket.latest_started_at ? ` · zuletzt ${formatDateTime(ticket.latest_started_at)}` : ""}
</span>
</button>
))
@@ -489,7 +505,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
</span>
{timer.organizationName ? (
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
{timer.organizationName}
{timer.isProject ? "Projekt · " : ""}
{timer.projectName || timer.organizationName}
</span>
) : null}
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
@@ -631,13 +648,17 @@ export function App() {
return {
id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? null
workType: result.ticket?.work_type ?? null,
isProject: Boolean(result.ticket?.is_project),
projectName: result.ticket?.project_name ?? null
};
} catch {
return {
id: timer.id,
organizationName: null,
workType: null
workType: null,
isProject: false,
projectName: null
};
}
})
@@ -659,6 +680,8 @@ export function App() {
...timer,
organizationName: result.organizationName,
workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true
};
})
@@ -727,6 +750,8 @@ export function App() {
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,
@@ -737,7 +762,7 @@ export function App() {
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", {
description: ticketNumber
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
});
return true;
}
@@ -870,6 +895,8 @@ export function App() {
{ type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator" as const, key: "after-monthly-close" },
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
@@ -1130,6 +1157,8 @@ export function App() {
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
+92
View File
@@ -5,11 +5,15 @@ import type {
Organization,
PeriodOverview,
PeriodType,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
TicketMeta,
TicketPeriod,
TicketTodo,
TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType
} from "./types";
@@ -290,6 +294,94 @@ export function deleteRecurringBilling(billingId: string) {
});
}
export function getProjects() {
return request<{ projects: ProjectSummary[] }>("/api/projects");
}
export function getProject(projectId: string) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`);
}
export function createProject(payload: {
ticketNumber: string;
organizationId?: string | null;
teamspaceProject?: string | null;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
}) {
return request<{ project: ProjectSummary }>("/api/projects", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateProject(
projectId: string,
payload: {
ticketNumber: string;
organizationId: string;
teamspaceProject?: string | null;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
}
) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function getTodos(status?: TodoStatus | "all") {
const params = new URLSearchParams();
if (status && status !== "all") {
params.set("status", status);
}
return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`);
}
export function createTicketTodo(
ticketId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
}
) {
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateTodo(
todoId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
}
) {
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteTodo(todoId: string) {
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
method: "DELETE"
});
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
method: "POST",
+235
View File
@@ -0,0 +1,235 @@
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
import { FormEvent, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
const priorityLabels: Record<TodoPriority, string> = {
low: "Niedrig",
medium: "Normal",
high: "Hoch",
urgent: "Dringend"
};
const priorityClasses: Record<TodoPriority, string> = {
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
};
type TodoPanelProps = {
ticketId?: string;
todos: TicketTodo[];
onChanged: () => Promise<void> | void;
showCreate?: boolean;
showTicketMeta?: boolean;
onNavigate?: (to: string) => void;
};
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
const [busyId, setBusyId] = useState<string | null>(null);
async function submitTodo(event: FormEvent) {
event.preventDefault();
if (!ticketId) {
return;
}
setSaving(true);
try {
await createTicketTodo(ticketId, { title, description, priority });
setTitle("");
setDescription("");
setPriority("medium");
toast.success("ToDo angelegt");
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSaving(false);
}
}
function startEdit(todo: TicketTodo) {
setEditingId(todo.id);
setEditTitle(todo.title);
setEditDescription(todo.description);
setEditPriority(todo.priority);
}
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
setBusyId(todo.id);
try {
await updateTodo(todo.id, {
title: editingId === todo.id ? editTitle : todo.title,
description: editingId === todo.id ? editDescription : todo.description,
priority: editingId === todo.id ? editPriority : todo.priority,
status
});
setEditingId(null);
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
async function removeTodo(todo: TicketTodo) {
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
if (!confirmed) {
return;
}
setBusyId(todo.id);
try {
await deleteTodo(todo.id);
toast.success("ToDo gelöscht");
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-3">
{showCreate && ticketId ? (
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
className="h-9 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={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
<Textarea
className="min-h-16 lg:col-span-3"
placeholder="Details optional"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</form>
) : null}
<div className="space-y-2">
{todos.map((todo) => {
const isEditing = editingId === todo.id;
return (
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
{showTicketMeta ? (
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
{todo.ticket_number}
</button>
{todo.project_id ? <Badge variant="outline">Projekt</Badge> : null}
{todo.organization_name ? <span>{todo.organization_name}</span> : null}
</div>
) : null}
{isEditing ? (
<div className="space-y-2">
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
<div className="flex flex-wrap items-center justify-between gap-2">
<select
className="h-8 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={editPriority}
onChange={(event) => setEditPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex gap-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
<Button type="button" size="sm" disabled={busyId === todo.id} onClick={() => void saveTodo(todo)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
disabled={busyId === todo.id}
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
>
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
</span>
</button>
<div className="flex shrink-0 items-center gap-1">
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
<Pencil className="size-4" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" onClick={() => void removeTodo(todo)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
</div>
);
}
+37
View File
@@ -237,6 +237,43 @@ export const helpSections: HelpSection[] = [
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
]
},
{
id: "projekte",
title: "Projekte",
description: "Projekte erweitern ein normales Ticket um Consulting-Projektinformationen und bleiben trotzdem über dieselbe Ticketdetailansicht bearbeitbar.",
quickItems: [
"Projekte werden über eine Ticketnummer angelegt.",
"Vorhandene Ticketdaten werden automatisch übernommen.",
"Projekt-Sessions sind immer Consulting."
],
details: [
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Auswertung, geöffnete Tickets und ToDos geöffnet werden.",
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung, Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
]
},
{
id: "todos",
title: "ToDos",
description: "ToDos sind kleine Aufgabenlisten pro Ticket oder Projekt, damit offene Arbeit sichtbar bleibt.",
quickItems: [
"ToDos werden direkt im Ticket angelegt.",
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
"Prioritäten helfen beim Sortieren der nächsten Schritte."
],
details: [
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung und Priorität an.",
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
"ToDos sind benutzerbezogen. Andere Benutzer sehen ihre eigenen Aufgaben, auch wenn mehrere Benutzer am gleichen Ticket arbeiten."
]
},
{
id: "sessions-bearbeiten",
title: "Sessions Bearbeiten und Löschen",
+2
View File
@@ -5,6 +5,8 @@ export type TimerEntry = {
ticketNumber: string;
organizationName?: string | null;
workType?: "support" | "consulting" | null;
isProject?: boolean;
projectName?: string | null;
ticketLookupDone?: boolean;
startedAt: number;
pausedTotalMs: number;
+50 -1
View File
@@ -60,6 +60,9 @@ export type TicketMeta = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
};
export type TicketSearchResult = {
@@ -69,12 +72,56 @@ export type TicketSearchResult = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
latest_started_at: string;
project_id: string | null;
is_project: boolean;
project_name: string | null;
latest_started_at: string | null;
latest_period: string;
session_count: number;
total_minutes: number;
};
export type ProjectSummary = {
id: string;
ticket_id: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
teamspace_project: string | null;
description: string;
goals: string;
starts_on: string | null;
ends_on: string | null;
hour_budget_minutes: number | null;
created_at: string;
updated_at: string;
session_count?: number;
billed_minutes?: number;
open_todos?: number;
done_todos?: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
export type TodoStatus = "open" | "done";
export type TicketTodo = {
id: string;
ticket_id: string;
ticket_number?: string;
organization_name?: string | null;
project_id?: string | null;
teamspace_project?: string | null;
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
completed_at: string | null;
created_at: string;
updated_at: string;
};
export type TicketSummary = {
id: string;
ticket_number: string;
@@ -190,6 +237,8 @@ export type TicketPeriod = {
};
sessions: SessionEntry[];
dayBillings: TicketDayBilling[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
canClose: boolean;
openCount: number;
ticketOpenCount: number;
+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>
);
}