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,
|
updateSessionBilling,
|
||||||
updateSessionDetails,
|
updateSessionDetails,
|
||||||
updateTicket,
|
updateTicket,
|
||||||
updateTicketDayBilling
|
updateTicketDayBilling,
|
||||||
|
updateProject
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type TicketDetailPageProps = {
|
type TicketDetailPageProps = {
|
||||||
@@ -54,6 +55,18 @@ type TicketSessionFormState = {
|
|||||||
endTime: string;
|
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 {
|
function emptyTicketSessionForm(): TicketSessionFormState {
|
||||||
return {
|
return {
|
||||||
organizationId: "",
|
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({
|
function BillingButtons({
|
||||||
session,
|
session,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -182,6 +209,20 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
||||||
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||||
const [creatingProject, setCreatingProject] = useState(false);
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -295,6 +336,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
editOrganizationId !== (ticket.organization_id ?? "") ||
|
editOrganizationId !== (ticket.organization_id ?? "") ||
|
||||||
editWorkType !== (ticket.work_type ?? "support"))
|
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));
|
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
async function submitTicketSession(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -761,8 +864,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
|
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
|
||||||
</div>
|
</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" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 px-4 pb-4">
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
@@ -790,6 +899,96 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -800,11 +999,27 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
|
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
|
||||||
</div>
|
</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" />
|
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<TodoPanel ticketId={ticket?.id} todos={data?.todos ?? []} onChanged={load} />
|
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user