This commit is contained in:
michael committed 2026-09-15 14:00:30 +00:00
1 parent 8288d9f7bc
commit 62d8f537a8
1 file changed
+108 -15
+108 -15
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, EyeOff, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; 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 { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
@@ -204,6 +204,47 @@ function sessionDayKey(value: string) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; 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) { function toDateLocalValue(value: string) {
const date = new Date(value); const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60_000; const offsetMs = date.getTimezoneOffset() * 60_000;
@@ -448,6 +489,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open"); const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]); const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false); const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false);
const [teamspacePreparation, setTeamspacePreparation] = useState<{ day: string; label: string; text: string } | null>(null);
async function load() { async function load() {
try { try {
@@ -964,6 +1006,31 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditSessionEndTime(toTimeLocalValue(session.ended_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) { async function saveSessionData(event: FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -1130,11 +1197,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</Button> </Button>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2> <h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{isProject ? <Badge variant="outline">Projekt</Badge> : null} {isProject ? <Badge variant="outline">Projekt</Badge> : null}
<ZammadStateBadge stateId={ticket?.zammad_state_id} /> <ZammadStateBadge stateId={ticket?.zammad_state_id} />
<ZammadPriorityBadge priorityId={ticket?.zammad_priority_id} /> <ZammadPriorityBadge priorityId={ticket?.zammad_priority_id} />
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null} {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{ticket?.title ? `${ticket.ticket_number} · ` : ""} {ticket?.title ? `${ticket.ticket_number} · ` : ""}
@@ -1199,19 +1267,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="non_billable">Nicht abrechenbar</option> <option value="non_billable">Nicht abrechenbar</option>
</select> </select>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-source-filter">Quelle</label>
<select
id="ticket-source-filter"
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={globalSourceFilter}
onChange={(event) => setGlobalSourceFilter(event.currentTarget.value as typeof globalSourceFilter)}
>
<option value="all">Alle Quellen</option>
<option value="manual">Manuell</option>
<option value="recurring">Fixe Abrechnung</option>
</select>
</div>
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
@@ -1579,6 +1634,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p> <p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="secondary" className="h-7" onClick={() => openTeamspacePreparation(group)}>
<ClipboardList className="size-3.5" />
TS Aufbereitung
</Button>
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge> <Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null} {group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null} {group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
@@ -1847,6 +1906,40 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</p> </p>
) : null} ) : null}
<Dialog open={Boolean(teamspacePreparation)} onOpenChange={(open) => !open && setTeamspacePreparation(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">
<DialogTitle>TS Aufbereitung</DialogTitle>
<DialogDescription>{teamspacePreparation?.label ?? "Sessiontätigkeiten des Tages"}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
className="min-h-80 resize-y font-mono text-sm"
value={teamspacePreparation?.text ?? ""}
onChange={(event) =>
setTeamspacePreparation((current) =>
current
? {
...current,
text: event.currentTarget.value
}
: current
)
}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" onClick={() => setTeamspacePreparation(null)}>
Schließen
</Button>
<Button type="button" disabled={!teamspacePreparation?.text.trim()} onClick={() => void copyTeamspacePreparation()}>
<Copy className="size-4" />
Kopieren
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}> <Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>