Refine project and todo workflow

This commit is contained in:
michael committed 2026-09-02 11:23:34 +02:00
1 parent 3da436423f
commit 239be9f218
9 files changed
+487 -301

No files matched your search

+5 -3
View File
@@ -400,12 +400,13 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
<span className="truncate text-sm font-medium">{ticket.title || 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.title ? `${ticket.ticket_number} · ` : ""}
{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>
@@ -892,11 +893,12 @@ export function App() {
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator" as const, key: "after-analysis" },
{ 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: "separator" as const, key: "after-work-items" },
{ 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") },
+3
View File
@@ -304,6 +304,7 @@ export function getProject(projectId: string) {
export function createProject(payload: {
ticketNumber: string;
title?: string | null;
organizationId?: string | null;
teamspaceProject?: string | null;
description: string;
@@ -322,6 +323,7 @@ export function updateProject(
projectId: string,
payload: {
ticketNumber: string;
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
description: string;
@@ -401,6 +403,7 @@ export function updateTicket(
ticketId: string,
payload: {
ticketNumber: string;
title?: string | null;
organizationId: string;
workType: WorkType;
}
+61 -35
View File
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
@@ -38,6 +39,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
@@ -57,6 +59,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
setTitle("");
setDescription("");
setPriority("medium");
setCreateOpen(false);
toast.success("ToDo angelegt");
await onChanged();
} catch (error) {
@@ -119,41 +122,64 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
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>
<div className="flex justify-end">
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<Plus className="size-4" />
ToDo anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ToDo anlegen</DialogTitle>
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
</DialogHeader>
<form className="space-y-4" 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)} autoFocus 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>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
Details
</label>
<Textarea
id={`todo-description-${ticketId}`}
className="min-h-24"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
) : null}
<div className="space-y-2">
+5
View File
@@ -56,6 +56,7 @@ export type RecurringBilling = {
export type TicketMeta = {
id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
@@ -68,6 +69,7 @@ export type TicketMeta = {
export type TicketSearchResult = {
ticket_id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
@@ -85,6 +87,7 @@ export type ProjectSummary = {
id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
@@ -125,6 +128,7 @@ export type TicketTodo = {
export type TicketSummary = {
id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
@@ -226,6 +230,7 @@ export type TicketPeriod = {
ticket: {
id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
+5 -2
View File
@@ -437,6 +437,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const matchesQuery =
!normalizedQuery ||
ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) ||
(ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) ||
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType;
const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization;
@@ -823,9 +824,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableRow key={ticket.id}>
<TableCell>
<div className="flex items-center gap-1">
<span className="font-semibold">{ticket.ticket_number}</span>
<span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
<CopyTicketButton ticketNumber={ticket.ticket_number} />
</div>
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
</TableCell>
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
@@ -874,9 +876,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1">
<p className="truncate font-semibold">{ticket.ticket_number}</p>
<p className="truncate font-semibold">{ticket.title || ticket.ticket_number}</p>
<CopyTicketButton ticketNumber={ticket.ticket_number} />
</div>
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
</div>
<TicketStatus ticket={ticket} />
+172 -124
View File
@@ -1,10 +1,11 @@
import { CalendarDays, FolderKanban, Play, Save, Search, Target } from "lucide-react";
import { CalendarDays, FolderKanban, Play, Plus, 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
@@ -22,6 +23,7 @@ type ProjectsPageProps = {
type ProjectForm = {
ticketNumber: string;
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
@@ -35,6 +37,7 @@ type ProjectForm = {
function emptyProjectForm(): ProjectForm {
return {
ticketNumber: "",
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
@@ -73,6 +76,7 @@ function parseHoursInput(value: string) {
function formFromProject(project: ProjectSummary): ProjectForm {
return {
ticketNumber: project.ticket_number,
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
@@ -84,6 +88,120 @@ function formFromProject(project: ProjectSummary): ProjectForm {
};
}
type ProjectFormFieldsProps = {
form: ProjectForm;
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
saving: boolean;
submitLabel: string;
onSubmit: (event: FormEvent) => void;
};
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
<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. Daten werden ü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" htmlFor="project-title">
Titel
</label>
<Input id="project-title" value={form.title} onChange={(event) => setForm({ ...form, title: event.currentTarget.value })} />
</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(240px,1fr)_minmax(220px,1fr)]">
<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" htmlFor="teamspace-project">
Teamspace-Projekt
</label>
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
</div>
</div>
<div className="grid gap-3 lg:grid-cols-3">
<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..." : submitLabel}
</Button>
</div>
</form>
);
}
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
@@ -91,6 +209,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
@@ -141,6 +260,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
current.ticketNumber.trim() === normalizedTicketNumber
? {
...current,
title: current.title || result.ticket?.title || "",
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
}
@@ -197,6 +317,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
try {
const result = await createProject({
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
description: form.description,
@@ -209,6 +330,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
});
setForm(emptyProjectForm());
setCreateOpen(false);
setLookupResult(null);
setLookupStatus("idle");
await load();
@@ -238,6 +360,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
try {
await updateProject(project.id, {
ticketNumber: editForm.ticketNumber,
title: editForm.title || null,
organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject,
description: editForm.description,
@@ -265,126 +388,48 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<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 className="flex flex-wrap items-center gap-2">
<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>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button">
<Plus className="size-4" />
Projekt anlegen
</Button>
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
saving={saving}
submitLabel="Projekt anlegen"
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</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;
@@ -399,10 +444,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<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>
<CardTitle className="truncate text-base">{project.title || project.ticket_number}</CardTitle>
<Badge variant="outline">Projekt</Badge>
</div>
<CardDescription className="truncate">{project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}</CardDescription>
<CardDescription className="truncate">
{project.ticket_number} · {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)}>
@@ -418,7 +465,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</CardHeader>
<CardContent className="space-y-3 p-4">
{isEditing ? (
<div className="space-y-3">
<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
@@ -429,6 +476,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
/>
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div>
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
<Input
placeholder="Teamspace-Projekt"
value={editForm.teamspaceProject}
@@ -454,6 +502,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
) : (
<>
<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">Ticket</span>
<span className="line-clamp-1 font-medium">{project.ticket_number}</span>
</div>
<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>
@@ -462,10 +514,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<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 ? (
+183 -118
View File
@@ -1,7 +1,8 @@
import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Save, Target, Trash2 } from "lucide-react";
import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Plus, Save, Target, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -13,6 +14,7 @@ import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createProject,
createSession,
deleteSession,
getTicketDetails,
@@ -159,6 +161,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingTicket, setSavingTicket] = useState(false);
const [editTicketNumber, setEditTicketNumber] = useState("");
const [editTicketTitle, setEditTicketTitle] = useState("");
const [editOrganizationId, setEditOrganizationId] = useState("");
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
@@ -178,6 +181,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [creatingProject, setCreatingProject] = useState(false);
async function load() {
try {
@@ -220,6 +224,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
setEditTicketNumber(data.ticket.ticket_number);
setEditTicketTitle(data.ticket.title ?? "");
setEditOrganizationId(data.ticket.organization_id ?? "");
setEditOrganizationName(data.ticket.organization_name ?? null);
setEditWorkType(data.ticket.work_type ?? "support");
@@ -286,6 +291,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const ticketDataDirty = Boolean(
ticket &&
(editTicketNumber !== ticket.ticket_number ||
editTicketTitle !== (ticket.title ?? "") ||
editOrganizationId !== (ticket.organization_id ?? "") ||
editWorkType !== (ticket.work_type ?? "support"))
);
@@ -387,6 +393,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try {
await updateTicket(ticket.id, {
ticketNumber: editTicketNumber,
title: editTicketTitle || null,
organizationId: editOrganizationId,
workType: isProject ? "consulting" : editWorkType
});
@@ -403,6 +410,44 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
}
async function createProjectFromTicket() {
if (!ticket) {
return;
}
if (!ticket.organization_id) {
toast.error("Organisation fehlt", {
description: "Speichere zuerst eine Organisation am Ticket, bevor daraus ein Projekt wird."
});
return;
}
setCreatingProject(true);
try {
await createProject({
ticketNumber: ticket.ticket_number,
title: ticket.title ?? null,
organizationId: ticket.organization_id,
teamspaceProject: ticket.title ?? "",
description: "",
goals: "",
startsOn: null,
endsOn: null,
hourBudgetMinutes: null
});
toast.success("Projekt angelegt", {
description: "Das Ticket ist jetzt als Consulting-Projekt markiert."
});
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingProject(false);
}
}
async function submitTicketSession(event: FormEvent) {
event.preventDefault();
@@ -563,16 +608,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
Zur Auswertung
</Button>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.ticket_number ?? "Ticket"}</h2>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
</div>
<p className="text-sm text-muted-foreground">Sessions {sessionsScopeLabel}</p>
<p className="text-sm text-muted-foreground">
{ticket?.title ? `${ticket.ticket_number} · ` : ""}
Sessions {sessionsScopeLabel}
</p>
</div>
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
</Badge>
<div className="flex flex-wrap items-center gap-2">
{!isProject && ticket ? (
<Button type="button" size="sm" variant="secondary" disabled={creatingProject} onClick={() => void createProjectFromTicket()}>
<Plus className="size-4" />
{creatingProject ? "Erstellt..." : "Als Projekt anlegen"}
</Button>
) : null}
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
</Badge>
</div>
</div>
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
@@ -647,11 +704,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_auto] lg:items-end" onSubmit={saveTicketData}>
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={saveTicketData}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
{ticket?.customer_name && !ticket.organization_id ? (
@@ -703,29 +764,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<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>
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
</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 className="grid gap-3 text-sm lg:grid-cols-2">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</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 className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
</div>
</CardContent>
@@ -769,102 +827,109 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="space-y-3">
<Accordion type="multiple" className="gap-2">
{sessionGroups.map((group) => (
<div key={group.key} className="rounded-md border bg-background">
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium">{group.label}</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Tag abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
type="text"
className="h-7 w-24 bg-background text-right"
autoComplete="off"
inputMode="decimal"
value={dayBillingInputs[group.key] ?? ""}
disabled={isPeriodClosed || savingDayBillingKey === group.key}
placeholder="0"
onChange={(event) => {
const nextValue = event.currentTarget.value;
if (!isPartialHoursInput(nextValue)) {
return;
}
setDayBillingInputs((current) => ({
...current,
[group.key]: nextValue
}));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
/>
<span>h</span>
<Button
type="button"
size="sm"
className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
disabled={isPeriodClosed || !isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void saveDayBilling(group.key)}
>
<Save className="size-3.5" />
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
</Button>
<AccordionItem key={group.key} value={group.key} className="rounded-md border bg-background px-3">
<AccordionTrigger className="gap-3 py-2 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 text-left">
<p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
</div>
</div>
<div className="space-y-1.5 p-2 sm:p-3">
{group.sessions.map((session) => (
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
<div className="text-sm text-muted-foreground">
{formatTimeRange(session.started_at, session.ended_at)}
</div>
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
</div>
<BillingButtons
session={session}
disabled={isPeriodClosed}
loading={loadingId === session.id}
onChange={(value) => setBilling(session.id, value)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground" onClick={(event) => event.stopPropagation()}>
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
type="text"
className="h-7 w-24 bg-background text-right"
autoComplete="off"
inputMode="decimal"
value={dayBillingInputs[group.key] ?? ""}
disabled={isPeriodClosed || savingDayBillingKey === group.key}
placeholder="0"
onChange={(event) => {
const nextValue = event.currentTarget.value;
if (!isPartialHoursInput(nextValue)) {
return;
}
setDayBillingInputs((current) => ({
...current,
[group.key]: nextValue
}));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
/>
<div className="flex gap-1 lg:justify-end">
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}>
<Pencil className="size-4" />
<span className="sr-only">Session bearbeiten</span>
</Button>
<Button
size="icon"
variant="ghost"
className="size-8 text-destructive hover:text-destructive"
disabled={deletingId === session.id}
onClick={() => removeSession(session)}
>
<Trash2 className="size-4" />
<span className="sr-only">Session löschen</span>
</Button>
</div>
<span>h</span>
<Button
type="button"
size="sm"
className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
disabled={isPeriodClosed || !isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void saveDayBilling(group.key)}
>
<Save className="size-3.5" />
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
</Button>
</div>
</div>
))}
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="space-y-1.5">
{group.sessions.map((session) => (
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
<div className="text-sm text-muted-foreground">
{formatTimeRange(session.started_at, session.ended_at)}
</div>
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
</div>
<BillingButtons
session={session}
disabled={isPeriodClosed}
loading={loadingId === session.id}
onChange={(value) => setBilling(session.id, value)}
/>
<div className="flex gap-1 lg:justify-end">
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}>
<Pencil className="size-4" />
<span className="sr-only">Session bearbeiten</span>
</Button>
<Button
size="icon"
variant="ghost"
className="size-8 text-destructive hover:text-destructive"
disabled={deletingId === session.id}
onClick={() => removeSession(session)}
>
<Trash2 className="size-4" />
<span className="sr-only">Session löschen</span>
</Button>
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</div>
</Accordion>
{data?.sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>