Allow project editing from ticket details
This commit is contained in:
1 parent
239be9f218
commit
3d9674bd5a
1 file changed
+218
-3
@@ -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,8 +864,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
</CardTitle>
|
||||
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user