Allow project editing from ticket details

This commit is contained in:
michael committed 2026-09-02 11:28:55 +02:00
1 parent 239be9f218
commit 3d9674bd5a
1 file changed
+220 -5
+220 -5
View File
@@ -22,10 +22,11 @@ import {
updateSessionBilling,
updateSessionDetails,
updateTicket,
updateTicketDayBilling
updateTicketDayBilling,
updateProject
} from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import type { BillingStatus, PeriodType, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -54,6 +55,18 @@ type TicketSessionFormState = {
endTime: string;
};
type ProjectEditFormState = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
goals: string;
startsOn: string;
endsOn: string;
hourBudget: string;
};
function emptyTicketSessionForm(): TicketSessionFormState {
return {
organizationId: "",
@@ -66,6 +79,20 @@ function emptyTicketSessionForm(): TicketSessionFormState {
};
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
return {
title: project.title ?? "",
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)
};
}
function BillingButtons({
session,
disabled,
@@ -182,6 +209,20 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [creatingProject, setCreatingProject] = useState(false);
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
goals: "",
startsOn: "",
endsOn: "",
hourBudget: ""
});
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
async function load() {
try {
@@ -295,6 +336,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
editOrganizationId !== (ticket.organization_id ?? "") ||
editWorkType !== (ticket.work_type ?? "support"))
);
const todos = data?.todos ?? [];
const visibleTodos = todoFilter === "all" ? todos : todos.filter((todo) => todo.status === todoFilter);
const todoCounts = {
open: todos.filter((todo) => todo.status === "open").length,
done: todos.filter((todo) => todo.status === "done").length,
all: todos.length
};
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
useEffect(() => {
@@ -448,6 +496,61 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
}
function openProjectEditor() {
if (!project) {
return;
}
setProjectForm(projectEditForm(project));
setEditingProject(true);
}
async function saveProjectData(event: FormEvent) {
event.preventDefault();
if (!project || !ticket) {
return;
}
if (!projectForm.organizationId) {
toast.error("Organisation wählen");
return;
}
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
setSavingProject(true);
try {
await updateProject(project.id, {
ticketNumber: ticket.ticket_number,
title: projectForm.title || null,
organizationId: projectForm.organizationId,
teamspaceProject: projectForm.teamspaceProject || null,
description: projectForm.description,
goals: projectForm.goals,
startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt gespeichert");
setEditingProject(false);
await load();
} catch (error) {
toast.error("Projekt konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProject(false);
}
}
async function submitTicketSession(event: FormEvent) {
event.preventDefault();
@@ -761,7 +864,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
</div>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
<div className="flex shrink-0 items-center gap-1">
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
@@ -790,6 +899,96 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</Card>
) : null}
<Dialog open={editingProject} onOpenChange={setEditingProject}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Projekt bearbeiten</DialogTitle>
<DialogDescription>Projektinformationen zum geöffneten Ticket aktualisieren.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={saveProjectData}>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-title">Titel</label>
<Input
id="project-edit-title"
value={projectForm.title}
onChange={(event) => setProjectForm({ ...projectForm, title: event.currentTarget.value })}
placeholder="optional"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-teamspace">Teamspace-Projekt</label>
<Input
id="project-edit-teamspace"
value={projectForm.teamspaceProject}
onChange={(event) => setProjectForm({ ...projectForm, teamspaceProject: event.currentTarget.value })}
/>
</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">Organisation</label>
<OrganizationSelect
value={projectForm.organizationId}
selectedName={projectForm.organizationName}
onChange={(organization) =>
setProjectForm({
...projectForm,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-start">Von</label>
<Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label>
<Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
<Input
id="project-edit-budget"
inputMode="decimal"
value={projectForm.hourBudget}
onChange={(event) => setProjectForm({ ...projectForm, 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-edit-description">Projektbeschreibung</label>
<Textarea
id="project-edit-description"
className="min-h-28"
value={projectForm.description}
onChange={(event) => setProjectForm({ ...projectForm, description: event.currentTarget.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-goals">Ziele</label>
<Textarea
id="project-edit-goals"
className="min-h-28"
value={projectForm.goals}
onChange={(event) => setProjectForm({ ...projectForm, goals: event.currentTarget.value })}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={savingProject}>
<Save className="size-4" />
{savingProject ? "Speichert..." : "Projekt speichern"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -800,11 +999,27 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
<div className="flex shrink-0 items-center gap-2">
<div className="flex rounded-md border bg-muted/20 p-0.5">
{(["open", "all", "done"] as const).map((value) => (
<Button
key={value}
type="button"
size="sm"
variant={todoFilter === value ? "default" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setTodoFilter(value)}
>
{value === "open" ? `Offen ${todoCounts.open}` : value === "done" ? `Erledigt ${todoCounts.done}` : `Alle ${todoCounts.all}`}
</Button>
))}
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={data?.todos ?? []} onChanged={load} />
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
</CardContent>
</Card>