This commit is contained in:
michael committed 2026-09-09 12:55:56 +02:00
1 parent c2063fe1ed
commit 07ef8ef025
10 files changed
+1624 -93

No files matched your search

+186 -11
View File
@@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
deleteProject,
deleteSession,
addFavoriteTicket,
getDashboardPreference,
@@ -53,6 +54,7 @@ type TicketDetailPageProps = {
type TicketSessionFormState = {
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
workType: WorkType;
day: string;
@@ -82,6 +84,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
return {
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
workType: "support",
day: currentDay(),
@@ -281,6 +284,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
@@ -293,6 +297,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
@@ -379,10 +384,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
const [editSessionActivity, setEditSessionActivity] = useState("");
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
const [editSessionDay, setEditSessionDay] = useState("");
const [editSessionStartTime, setEditSessionStartTime] = useState("");
const [editSessionEndTime, setEditSessionEndTime] = useState("");
@@ -396,6 +403,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
const [deletingEmptyProject, setDeletingEmptyProject] = useState(false);
const [savingProjectStatus, setSavingProjectStatus] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
title: "",
@@ -463,13 +471,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditOrganizationId(data.ticket.organization_id ?? "");
setEditOrganizationName(data.ticket.organization_name ?? null);
setEditWorkType(data.ticket.work_type ?? "support");
const loadedProjectEntries = data.project ? projectTeamspaceEntries(data.project) : [];
setTicketSession((current) => ({
...current,
organizationId: data.ticket.organization_id ?? current.organizationId,
organizationName: data.ticket.organization_name ?? current.organizationName,
projectTeamspaceEntryId: loadedProjectEntries.length === 1 ? loadedProjectEntries[0].id : current.projectTeamspaceEntryId,
workType: data.ticket.work_type ?? current.workType
}));
}, [data?.ticket]);
}, [data?.ticket, data?.project]);
useEffect(() => {
setProjectDescriptionExpanded(false);
@@ -481,9 +491,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const canEditProject = project?.can_edit !== false;
const canEditTicketData = !project || canEditProject;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -684,6 +697,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (!canEditTicketData) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."
});
return;
}
if (!editOrganizationId) {
toast.error("Organisation wählen");
return;
@@ -728,6 +748,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
setProjectForm(projectEditForm(project));
setEditingProject(true);
}
@@ -739,6 +766,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
if (!projectForm.organizationId) {
toast.error("Organisation wählen");
return;
@@ -787,6 +821,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
});
return;
}
setSavingProjectStatus(true);
try {
await updateProjectStatus(project.id, closed);
@@ -823,6 +864,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry && !ticketSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`);
@@ -836,6 +884,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry && !editSessionProjectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
setSavingTicketSession(true);
@@ -848,7 +903,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -857,6 +913,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
});
@@ -876,6 +933,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditSessionOrganizationName(session.organization_name ?? null);
setEditSessionActivity(session.activity);
setEditSessionWorkType(session.work_type);
setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? "");
setEditSessionDay(toDateLocalValue(session.started_at));
setEditSessionStartTime(toTimeLocalValue(session.started_at));
setEditSessionEndTime(toTimeLocalValue(session.ended_at));
@@ -925,6 +983,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
activity: editSessionActivity,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
@@ -951,6 +1010,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try {
const result = await deleteSession(session.id);
if (result.deleted.projectDeleteAvailable && result.deleted.project_id) {
setPendingProjectDelete({ projectId: result.deleted.project_id });
toast.success("Session gelöscht", {
description: "Für dieses Projekt sind keine Sessions mehr vorhanden."
});
return;
}
if (result.deleted.ticketDeleted || result.deleted.userTicketEmpty) {
toast.success("Letzte Session gelöscht", {
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
@@ -972,6 +1039,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
}
async function keepEmptyProject() {
setPendingProjectDelete(null);
toast.success("Projekt behalten");
await load();
}
async function deleteEmptyProject() {
if (!pendingProjectDelete) {
return;
}
setDeletingEmptyProject(true);
try {
await deleteProject(pendingProjectDelete.projectId);
toast.success("Projekt gelöscht");
setPendingProjectDelete(null);
onNavigate(returnPath);
} catch (error) {
toast.error("Projekt konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setDeletingEmptyProject(false);
}
}
async function toggleTicketFavorite() {
if (!ticket?.ticket_number) {
return;
@@ -1101,7 +1194,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."}
</CardDescription>
</div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
@@ -1110,11 +1207,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<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 />
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} 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" />
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} 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>
@@ -1124,6 +1221,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<OrganizationSelect
value={editOrganizationId}
selectedName={editOrganizationName}
disabled={!canEditTicketData}
onChange={(organization) => {
setEditOrganizationId(organization.id);
setEditOrganizationName(organization.name);
@@ -1137,13 +1235,14 @@ 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={!canEditTicketData}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<Button type="submit" disabled={savingTicket}>
<Button type="submit" disabled={!canEditTicketData || savingTicket}>
<Save className="size-4" />
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
@@ -1161,11 +1260,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</CardTitle>
<CardDescription>
{hasMultipleProjectTeamspaceEntries
? `${projectEntries.length} Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
{!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}`
: hasMultipleProjectTeamspaceEntries
? `${projectEntries.length} Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -1173,13 +1276,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
type="button"
size="sm"
variant="secondary"
disabled={savingProjectStatus}
disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
@@ -1188,6 +1291,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
<Alert variant="warning">
<div className="font-medium">Nicht zugeordnete Sessions</div>
<div className="text-sm text-muted-foreground">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. Bitte über die Session-Bearbeitung neu zuordnen.
</div>
</Alert>
) : null}
<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 {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
@@ -1497,6 +1608,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<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}
{requiresProjectTeamspaceEntry ? (
session.project_teamspace_entry_id ? (
<Badge variant="info">{session.project_teamspace_project || "Teamspace"}</Badge>
) : (
<Badge variant="warning">ohne Teamspace</Badge>
)
) : null}
</div>
<BillingButtons
session={session}
@@ -1604,6 +1722,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
)}
{requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="ticket-session-teamspace-entry"
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={ticketSession.projectTeamspaceEntryId}
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
@@ -1670,6 +1808,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</p>
) : null}
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keine Sessions mehr vorhanden</DialogTitle>
<DialogDescription>Projekt löschen?</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" disabled={deletingEmptyProject} onClick={() => void keepEmptyProject()}>
Projekt behalten
</Button>
<Button type="button" variant="destructive" disabled={deletingEmptyProject} onClick={() => void deleteEmptyProject()}>
<Trash2 className="size-4" />
{deletingEmptyProject ? "Löscht..." : "Projekt löschen"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingSession)} onOpenChange={(open) => !open && setEditingSession(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
@@ -1747,6 +1903,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="consulting">Consulting</option>
</select>
</div>
{editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="session-teamspace-entry"
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={editSessionProjectTeamspaceEntryId}
onChange={(event) => setEditSessionProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}>
<Save className="size-4" />