import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
deleteProject,
deleteSession,
hideProject,
addFavoriteTicket,
getDashboardPreference,
getTicketDetails,
getTicketPeriod,
removeFavoriteTicket,
updateSessionBilling,
updateSessionDetails,
updateTicket,
updateTicketDayBilling,
updateProject,
updateProjectStatus
} from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
periodType: TicketDetailPeriodType;
period: string;
ticketId: string;
onNavigate: (to: string) => void;
onOpenTicketChange?: (ticket: {
key: string;
ticketId: string;
ticketNumber: string;
periodType: TicketDetailPeriodType;
period: string;
path: string;
returnPath?: string;
dirty: boolean;
}) => void;
returnPath: string;
};
type TicketSessionFormState = {
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
endTime: string;
};
type ProjectEditFormState = {
title: string;
organizationId: string;
organizationName: string | null;
description: string;
startsOn: string;
endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
isDefault: boolean;
};
function emptyTicketSessionForm(): TicketSessionFormState {
return {
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
endTime: "09:30"
};
}
function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
const label = entry.teamspace_project || "Ohne Teamspace-ID";
return entry.is_default ? `${label} (Standard)` : label;
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return {
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
};
}
function combinedProjectDescription(project: ProjectSummary) {
return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n");
}
function BillingButtons({
session,
disabled,
loading,
onChange
}: {
session: SessionEntry;
disabled: boolean;
loading: boolean;
onChange: (status: BillingStatus) => void;
}) {
const status = session.billing_status;
return (
onChange(status === "billable" ? null : "billable")}
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
>
Abrechenbar
onChange(status === "billed" ? null : "billed")}
className={cn("h-8", status === "billed" ? "" : "bg-secondary/70")}
>
Abgerechnet
onChange(status === "non_billable" ? null : "non_billable")}
className={cn("h-8", status === "non_billable" ? "" : "bg-secondary/70")}
>
Nicht abrechenbar
);
}
function BillingCaptureSelect({
id,
value,
onChange
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
}) {
return (
onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude)}
>
---
Abrechenbar
Nicht abrechenbar
);
}
function sessionDayKey(value: string) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
async function writeTextToClipboard(value: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
if (typeof document === "undefined") {
throw new Error("Zwischenablage nicht verfügbar");
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.setAttribute("readonly", "");
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
if (!document.execCommand("copy")) {
throw new Error("Zwischenablage nicht verfügbar");
}
} finally {
document.body.removeChild(textarea);
}
}
function teamspacePreparationText(ticket: TicketPeriod["ticket"] | null | undefined, sessions: SessionEntry[]) {
const ticketNumber = ticket?.ticket_number ?? "Ticket#-";
const customerName = ticket?.organization_name ?? ticket?.customer_name ?? "-";
const activities = sessions
.slice()
.sort((left, right) => new Date(left.started_at).getTime() - new Date(right.started_at).getTime())
.map((session) => session.activity.trim())
.filter(Boolean)
.join("\n");
return [`${ticketNumber} - ${customerName}`, "", activities, "", "Tickethandling und Dokumentation"].join("\n");
}
function toDateLocalValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 10);
}
function toTimeLocalValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(11, 16);
}
function formatHoursInput(minutes?: number | null) {
if (minutes === null || minutes === undefined) {
return "";
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
}
function parseHoursInput(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (/[,.]$/.test(trimmed)) {
return undefined;
}
const hours = Number(trimmed.replace(",", "."));
if (!Number.isFinite(hours) || hours < 0) {
return undefined;
}
return Math.round(hours * 60);
}
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
}
if (percent >= 90) {
return "bg-red-500";
}
return "bg-emerald-500";
}
function projectConsumptionPercent(consumptionMinutes: number, budgetMinutes?: number | null) {
if (!budgetMinutes || budgetMinutes <= 0) {
return null;
}
return Math.round((consumptionMinutes / budgetMinutes) * 100);
}
function projectRemainingLabel(remainingMinutes: number | null) {
if (remainingMinutes === null) {
return "Rest offen";
}
if (remainingMinutes < 0) {
return `Überzogen ${formatMinutes(Math.abs(remainingMinutes))}`;
}
return `Rest ${formatMinutes(remainingMinutes)}`;
}
function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
}
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length
? entries
: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
isDefault: true
}
];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
manualConsumptionMinutes: number;
}>;
}
function ProjectTeamspaceEntriesEditor({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
Teamspace-Einträge
onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", isDefault: false }])}>
Eintrag
{normalizedEntries.map((entry, index) => (
))}
Der Standard-Eintrag erhält die gebuchten Teamspace-Tageswerte. Manuelle Stunden zählen nur im Projektverbrauch.
);
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
const [data, setData] = useState(null);
const [loadingId, setLoadingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [savingTicket, setSavingTicket] = useState(false);
const [savingFavorite, setSavingFavorite] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [editTicketNumber, setEditTicketNumber] = useState("");
const [editTicketTitle, setEditTicketTitle] = useState("");
const [editOrganizationId, setEditOrganizationId] = useState("");
const [editOrganizationName, setEditOrganizationName] = useState(null);
const [editWorkType, setEditWorkType] = useState("support");
const [editingSession, setEditingSession] = useState(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState(null);
const [editSessionActivity, setEditSessionActivity] = useState("");
const [editSessionWorkType, setEditSessionWorkType] = useState("support");
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
const [editSessionDay, setEditSessionDay] = useState("");
const [editSessionStartTime, setEditSessionStartTime] = useState("");
const [editSessionEndTime, setEditSessionEndTime] = useState("");
const [savingSessionDetails, setSavingSessionDetails] = useState(false);
const [dayBillingInputs, setDayBillingInputs] = useState>({});
const [savingDayBillingKey, setSavingDayBillingKey] = useState(null);
const [ticketSession, setTicketSession] = useState(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
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({
title: "",
organizationId: "",
organizationName: null,
description: "",
startsOn: "",
endsOn: "",
teamspaceEntries: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
isDefault: true
}
]
});
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
const [expandedSessionIds, setExpandedSessionIds] = useState([]);
const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false);
const [teamspacePreparation, setTeamspacePreparation] = useState<{ day: string; label: string; text: string } | null>(null);
async function load() {
try {
setData(
periodType === "all"
? await getTicketDetails(ticketId, {
month: globalMonthFilter,
status: globalStatusFilter,
source: globalSourceFilter
})
: await getTicketPeriod(periodType, period, ticketId)
);
} catch (error) {
toast.error("Ticket konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
useEffect(() => {
void load();
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
useEffect(() => {
if (periodType !== "all") {
return;
}
setGlobalMonthFilter(period !== "all" ? period : currentMonth());
}, [periodType, period, ticketId]);
useEffect(() => {
const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)]));
setDayBillingInputs(nextInputs);
}, [data?.dayBillings]);
useEffect(() => {
if (!data?.ticket) {
return;
}
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");
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?.project]);
useEffect(() => {
setProjectDescriptionExpanded(false);
}, [data?.project?.id]);
const ticket = data?.ticket;
const project = data?.project ?? null;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const budgetedProjectEntries = useMemo(() => (project ? budgetedProjectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const showBudgetedProjectEntries = budgetedProjectEntries.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 projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
const canEditProject = project?.can_edit !== false;
const canEditTicketData = !project || canEditProject;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket";
const sessionsScopeLabel = isGlobalTicket
? globalMonthFilter === "all"
? "im gesamten Ticketverlauf"
: `im Monat ${globalMonthFilter}`
: `im ${periodLabel.toLowerCase()} ${period}`;
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
const availableMonths = data?.availableMonths ?? [];
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]);
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
const sessionGroups = useMemo(() => {
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += billedMinutes;
group.billableMinutes += billableMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
}
groups.set(key, {
key,
label: formatDate(session.started_at),
totalMinutes: billedMinutes,
billableMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
});
}
return Array.from(groups.values());
}, [data?.sessions]);
const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
const periodTeamspaceMinutes = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0);
const ticketDataDirty = Boolean(
ticket &&
(editTicketNumber !== ticket.ticket_number ||
editTicketTitle !== (ticket.title ?? "") ||
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(() => {
setExpandedSessionIds((current) => current.filter((sessionId) => sessionIds.includes(sessionId)));
}, [sessionIds]);
useEffect(() => {
if (!ticket) {
return;
}
const ticketMonth = isGlobalTicket ? globalMonthFilter : periodType === "day" ? period.slice(0, 7) : period;
onOpenTicketChange?.({
key: `ticket:${ticket.id}`,
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
periodType: "all",
period: ticketMonth,
path: `/tickets/${ticket.id}?month=${ticketMonth}`,
returnPath,
dirty: hasUnsavedChanges
});
}, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, returnPath, hasUnsavedChanges, onOpenTicketChange]);
useEffect(() => {
let cancelled = false;
if (!ticket?.ticket_number) {
setIsFavorite(false);
return;
}
async function loadFavoriteStatus() {
try {
const result = await getDashboardPreference();
const favorites = Array.isArray(result.preference.favorites) ? result.preference.favorites : [];
if (!cancelled) {
setIsFavorite(favorites.some((favorite) => favorite.ticketNumber === ticket?.ticket_number));
}
} catch {
if (!cancelled) {
setIsFavorite(false);
}
}
}
void loadFavoriteStatus();
return () => {
cancelled = true;
};
}, [ticket?.ticket_number]);
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) {
toast.warning("Monat ist abgeschlossen", {
description: "Öffne den Monat zuerst wieder, um Bewertungen zu ändern."
});
return;
}
setLoadingId(sessionId);
try {
await updateSessionBilling(sessionId, billingStatus);
await load();
} catch (error) {
toast.error("Status konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoadingId(null);
}
}
function toggleSessionDetails(sessionId: string) {
setExpandedSessionIds((current) =>
current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [...current, sessionId]
);
}
function toggleAllSessionDetails() {
setExpandedSessionIds(allSessionsExpanded ? [] : sessionIds);
}
async function saveDayBilling(day: string) {
if (!ticket) {
return;
}
if (isPeriodClosed) {
toast.warning("Monat ist abgeschlossen", {
description: "Öffne den Monat zuerst wieder, um Teamspace-Zeiten zu ändern."
});
return;
}
const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? "");
if (parsedMinutes === undefined) {
toast.error("Teamspace-Zeit prüfen", {
description: "Bitte Stunden als positive Zahl eintragen, z.B. 1,5."
});
return;
}
const currentMinutes = dayBillingMap.get(day) ?? null;
if (parsedMinutes === currentMinutes) {
return;
}
setSavingDayBillingKey(day);
try {
await updateTicketDayBilling(ticket.id, day, parsedMinutes);
toast.success("Zeit gespeichert");
await load();
} catch (error) {
toast.error("Zeit konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingDayBillingKey(null);
}
}
async function saveTicketData(event: FormEvent) {
event.preventDefault();
if (!ticket) {
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;
}
const removeProjectData = Boolean(project && editWorkType === "support");
if (removeProjectData) {
const confirmed = window.confirm(
`Ticket "${ticket.title || ticket.ticket_number}" auf Support umstellen? Projektdaten, Kontingent und Teamspace-Zeiten werden entfernt. Sessions und ToDos bleiben erhalten.`
);
if (!confirmed) {
return;
}
}
setSavingTicket(true);
try {
await updateTicket(ticket.id, {
ticketNumber: editTicketNumber,
title: editTicketTitle || null,
organizationId: editOrganizationId,
workType: editWorkType,
removeProjectData
});
toast.success("Ticketdaten gespeichert", {
description: editWorkType === "consulting" ? "Das Ticket ist jetzt als Projekt angelegt." : "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
});
await load();
} catch (error) {
toast.error("Ticketdaten konnten nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTicket(false);
}
}
function openProjectEditor() {
if (!project) {
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);
}
async function saveProjectData(event: FormEvent) {
event.preventDefault();
if (!project || !ticket) {
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;
}
const teamspaceEntries = parseProjectTeamspaceEntryForms(projectForm.teamspaceEntries);
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
setSavingProject(true);
try {
await updateProject(project.id, {
ticketNumber: ticket.ticket_number,
title: projectForm.title || null,
organizationId: projectForm.organizationId,
teamspaceProject: defaultTeamspaceEntry.teamspaceProject,
teamspaceEntries,
description: projectForm.description,
goals: "",
startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null,
hourBudgetMinutes: defaultTeamspaceEntry.hourBudgetMinutes,
manualConsumptionMinutes: defaultTeamspaceEntry.manualConsumptionMinutes
});
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 setProjectClosed(closed: boolean) {
if (!project) {
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);
toast.success(closed ? "Projekt geschlossen" : "Projekt wieder geöffnet");
await load();
} catch (error) {
toast.error("Projektstatus konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatus(false);
}
}
async function submitTicketSession(event: FormEvent) {
event.preventDefault();
if (!ticket) {
return;
}
if (isRecurringBillingTicket) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
});
return;
}
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
const effectiveWorkType = ticket.work_type ?? ticketSession.workType;
if (!effectiveOrganizationId) {
toast.error("Organisation wählen");
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`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Datum oder Uhrzeit prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
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);
try {
await createSession({
ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId,
activity: ticketSession.activity,
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
});
setTicketSession({
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day,
billingStatus: null
});
await load();
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTicketSession(false);
}
}
function openSessionEditor(session: SessionEntry) {
setEditingSession(session);
setEditSessionOrganizationId(session.organization_id ?? "");
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));
}
function openTeamspacePreparation(group: (typeof sessionGroups)[number]) {
setTeamspacePreparation({
day: group.key,
label: group.label,
text: teamspacePreparationText(ticket, group.sessions)
});
}
async function copyTeamspacePreparation() {
if (!teamspacePreparation) {
return;
}
try {
await writeTextToClipboard(teamspacePreparation.text);
toast.success("TS Aufbereitung kopiert", {
description: teamspacePreparation.label
});
} catch (error) {
toast.error("TS Aufbereitung konnte nicht kopiert werden", {
description: error instanceof Error ? error.message : "Zwischenablage nicht verfügbar"
});
}
}
async function saveSessionData(event: FormEvent) {
event.preventDefault();
if (!editingSession) {
return;
}
if (!editSessionOrganizationId) {
toast.error("Organisation wählen");
return;
}
const startedAt = new Date(`${editSessionDay}T${editSessionStartTime}:00`);
const endedAt = new Date(`${editSessionDay}T${editSessionEndTime}:00`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Beginn und Ende prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
return;
}
const removeProjectData = Boolean(project && editingSession.work_type === "consulting" && editSessionWorkType === "support");
if (removeProjectData) {
const confirmed = window.confirm(
`Session auf Support umstellen? Falls dadurch keine Consulting-Sessions mehr für "${ticket?.title || ticket?.ticket_number || "dieses Ticket"}" bleiben, werden Projektdaten, Kontingent und Teamspace-Zeiten entfernt. Sessions und ToDos bleiben erhalten.`
);
if (!confirmed) {
return;
}
}
setSavingSessionDetails(true);
try {
await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId,
activity: editSessionActivity,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
toast.success("Session aktualisiert");
setEditingSession(null);
await load();
} catch (error) {
toast.error("Session konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingSessionDetails(false);
}
}
async function removeSession(session: SessionEntry) {
const confirmed = window.confirm(`Session vom ${formatDateTime(session.started_at)} wirklich löschen?`);
if (!confirmed) {
return;
}
setDeletingId(session.id);
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."
});
onNavigate(returnPath);
return;
}
toast.success("Session gelöscht", {
description: "Betroffene Abschlüsse wurden wieder geöffnet."
});
await load();
} catch (error) {
toast.error("Session konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setDeletingId(null);
}
}
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;
}
setSavingFavorite(true);
try {
if (isFavorite) {
await removeFavoriteTicket(ticket.ticket_number);
setIsFavorite(false);
toast.success("Favorit entfernt", {
description: ticket.ticket_number
});
return;
}
await addFavoriteTicket(ticket.ticket_number);
setIsFavorite(true);
toast.success("Favorit gespeichert", {
description: ticket.ticket_number
});
} catch (error) {
toast.error(isFavorite ? "Favorit konnte nicht entfernt werden" : "Favorit konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingFavorite(false);
}
}
return (
onNavigate(returnPath)}>
Zurück
{ticket?.title || ticket?.ticket_number || "Ticket"}
{isProject ? Projekt : null}
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? Monat geschlossen : null}
{ticket?.ticket_number ? : null}
{ticket?.title ? `${ticket.ticket_number} · ` : ""}
Sessions {sessionsScopeLabel}
{ticket?.ticket_number ? (
void toggleTicketFavorite()}>
{isFavorite ? "Favorit entfernen" : "Favorit"}
) : null}
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
) : null}
{isGlobalTicket ? (
Filter
Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global.
Monat
setGlobalMonthFilter(event.currentTarget.value)}
>
Alle Monate
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? {globalMonthFilter} (0) : null}
{availableMonths.map((month) => (
{month.month} ({month.session_count})
))}
Bewertung
setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
>
Alle Bewertungen
Offen
Abrechenbar
Nicht abrechenbar
) : null}
Ticketdaten
{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."}
{project ? (
Projekt
{project.closed_at ? Geschlossen : Offen }
{!canEditProject ? geteilt : null}
{project.close_review_required ? TS/TT prüfen : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? {project.unassigned_teamspace_session_count} ohne TS : null}
{!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? : }
{project.closed_at ? "Öffnen" : "Schließen"}
Bearbeiten
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
Nicht zugeordnete Sessions
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. Bitte über die Session-Bearbeitung neu zuordnen.
) : null}
{project.close_review_required ? (
Zeiten prüfen
Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
) : null}
Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}
Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}
Verbrauch {formatMinutes(projectConsumptionMinutes)}
{projectRemainingLabel(projectRemainingMinutes)}
{showBudgetedProjectEntries ? {budgetedProjectEntries.length} Budgets : null}
Budgetverbrauch
{projectBudgetMinutes && projectBudgetMinutes > 0
? `${formatMinutes(projectConsumptionMinutes)} / ${formatMinutes(projectBudgetMinutes)} · ${projectRemainingLabel(projectRemainingMinutes)}`
: `${formatMinutes(projectConsumptionMinutes)} / offen`}
{showBudgetedProjectEntries ? (
{budgetedProjectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
{label}
{entry.is_default ? " · Standard" : ""}
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
);
})}
) : null}
Beschreibung/Ziele
{projectDescription || "Noch keine Beschreibung/Ziele."}
{projectDescription ? (
setProjectDescriptionExpanded((current) => !current)}
>
{projectDescriptionExpanded ? (
<>
Einklappen
>
) : (
<>
Vollständig anzeigen
>
)}
) : null}
) : null}
Projekt bearbeiten
Projektinformationen zum geöffneten Ticket aktualisieren.
ToDos
Aufgaben zu diesem Ticket oder Projekt verwalten.
{(["open", "all", "done"] as const).map((value) => (
setTodoFilter(value)}
>
{value === "open" ? `Offen ${todoCounts.open}` : value === "done" ? `Erledigt ${todoCounts.done}` : `Alle ${todoCounts.all}`}
))}
Session-Einträge
Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.
Abgerechnet
{formatMinutes(periodTotalMinutes)}
{teamspaceLabel}
{formatMinutes(periodTeamspaceMinutes)}
{data?.sessions.length ? (
{allSessionsExpanded ? (
<>
Alle einklappen
>
) : (
<>
Alle aufklappen
>
)}
) : null}
{sessionGroups.map((group) => (
{group.label}
{group.sessions.length} Session(s)
openTeamspacePreparation(group)}>
TS Aufbereitung
Abgerechnet {formatMinutes(group.totalMinutes)}
Abrechenbar {formatMinutes(group.billableMinutes)}
0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
Offen {formatMinutes(group.openMinutes)}
Teamspace
{
const nextValue = event.currentTarget.value;
if (!isPartialHoursInput(nextValue)) {
return;
}
setDayBillingInputs((current) => ({
...current,
[group.key]: nextValue
}));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
/>
h
event.preventDefault()}
onClick={() => void saveDayBilling(group.key)}
>
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
{group.nonBillableMinutes > 0 ? (
Nicht {formatMinutes(group.nonBillableMinutes)}
) : null}
{group.sessions.map((session) => {
const expanded = expandedSessionIds.includes(session.id);
return (
toggleSessionDetails(session.id)}
aria-expanded={expanded}
>
{expanded ? : }
{formatTimeRange(session.started_at, session.ended_at)}
{formatMinutes(session.rounded_minutes)}
{session.recurring_billing_id ? fix : null}
{requiresProjectTeamspaceEntry ? (
session.project_teamspace_entry_id ? (
{session.project_teamspace_project || "Teamspace"}
) : (
ohne Teamspace
)
) : null}
setBilling(session.id, value)}
/>
openSessionEditor(session)}>
Session bearbeiten
removeSession(session)}
>
Session löschen
{expanded ? (
{session.activity}
{session.organization_name ?? session.customer_name}
{session.work_type === "support" ? "Support" : "Consulting"}
Erfasst {formatDateTime(session.created_at)}
) : null}
);
})}
))}
{data?.sessions.length === 0 ? (
Keine Sessions für dieses Ticket im gewählten Filter.
) : null}
{isRecurringBillingTicket ? (
Fixe Abrechnung
Sessions für dieses Ticket werden ausschließlich aus der Definition erzeugt. Manuelles Nachtragen ist deaktiviert.
) : (
Session nachtragen
Neue Zeit direkt diesem Ticket zuordnen.
{ticket?.organization_id && ticket.work_type ? (
{ticket.organization_name ?? ticket.customer_name}
{ticket.work_type === "support" ? "Support" : "Consulting"}
) : (
Organisation
{ticket?.customer_name && !ticket.organization_id ? (
Bisheriger Freitext: {ticket.customer_name}
) : null}
setTicketSession({
...ticketSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
Art
setTicketSession({ ...ticketSession, workType: event.currentTarget.value as WorkType })}
>
Support
Consulting
)}
{requiresProjectTeamspaceEntry ? (
Teamspace-Eintrag
setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
Bitte wählen
{projectEntries.map((entry) => (
{teamspaceEntryOptionLabel(entry)}
))}
) : null}
)}
{data && data.openCount > 0 ? (
Noch {data.openCount} Session(s) ohne Auswahl. Erst wenn im Monat keine Sessions mehr offen sind, kann der Monat abgeschlossen werden.
) : null}
!open && setTeamspacePreparation(null)}>
TS Aufbereitung
{teamspacePreparation?.label ?? "Sessiontätigkeiten des Tages"}
setTeamspacePreparation((current) =>
current
? {
...current,
text: event.currentTarget.value
}
: current
)
}
/>
setTeamspacePreparation(null)}>
Schließen
void copyTeamspacePreparation()}>
Kopieren
!open && void keepEmptyProject()}>
Keine Sessions mehr vorhanden
Projekt löschen?
void keepEmptyProject()}>
Projekt behalten
void deleteEmptyProject()}>
{deletingEmptyProject ? "Löscht..." : "Projekt löschen"}
!open && setEditingSession(null)}>
Session bearbeiten
Beginn, Ende, Tätigkeit und Stammdaten dieses Eintrags nachträglich korrigieren.
Organisation
{editingSession?.customer_name && !editingSession.organization_id ? (
Bisheriger Freitext: {editingSession.customer_name}
) : null}
{
setEditSessionOrganizationId(organization.id);
setEditSessionOrganizationName(organization.name);
}}
required
/>
Tätigkeit
setEditSessionActivity(event.currentTarget.value)}
required
/>
Art
setEditSessionWorkType(event.currentTarget.value as WorkType)}
>
Support
Consulting
{editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? (
Teamspace-Eintrag
setEditSessionProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
Bitte wählen
{projectEntries.map((entry) => (
{teamspaceEntryOptionLabel(entry)}
))}
) : null}
{savingSessionDetails ? "Speichert..." : "Session speichern"}
);
}