Files
tickettracker/frontend/src/views/MonthlyClosePage.tsx
T

449 lines
19 KiB
TypeScript

import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock3,
ExternalLink,
FileWarning,
Lock,
LockOpen,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useRef, 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 { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
type MonthlyClosePageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : "";
return (
<Card>
<CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</CardContent>
</Card>
);
}
type OpenTicketGroup = {
ticketId: string;
ticketNumber: string;
organizationName: string;
openCount: number;
totalMinutes: number;
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>();
for (const session of sessions) {
const existing = groups.get(session.ticket_id);
if (existing) {
existing.openCount += 1;
existing.totalMinutes += session.rounded_minutes;
continue;
}
groups.set(session.ticket_id, {
ticketId: session.ticket_id,
ticketNumber: session.ticket_number,
organizationName: session.organization_name,
openCount: 1,
totalMinutes: session.rounded_minutes
});
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
return (
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} />
<Badge variant="warning">{group.openCount} offen</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div>
<div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
Ticket öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
function CrmDayItem({
item,
busy,
onSetAcknowledgement,
onNavigate
}: {
item: StatisticsCrmDay;
busy: boolean;
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
onNavigate: (to: string) => void;
}) {
const isAcknowledged = Boolean(item.acknowledged);
return (
<div className={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} />
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getStatisticsOverview(month);
if (requestId === loadRequestId.current) {
setStats(result);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Monatsabschluss konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void load();
}, [month]);
useEffect(() => {
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
setShowAcknowledgedMissingCrmLoaded(true);
}, []);
useEffect(() => {
if (showAcknowledgedMissingCrmLoaded) {
window.localStorage.setItem(showAcknowledgedMissingCrmStorageKey, String(showAcknowledgedMissingCrm));
}
}, [showAcknowledgedMissingCrm, showAcknowledgedMissingCrmLoaded]);
function changeMonthBy(delta: number) {
const [year, monthNumber] = month.split("-").map(Number);
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
date.setMonth(date.getMonth() + delta);
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
}
async function finishMonth() {
setClosing(true);
try {
await closePeriod("month", month);
toast.success("Monat abgeschlossen", {
description: `${month} ist abgeschlossen.`
});
await load();
} catch (error) {
toast.error("Monat noch nicht abschließbar", {
description: error instanceof Error ? error.message : "Bitte offene Bewertungen prüfen."
});
await load();
} finally {
setClosing(false);
}
}
async function reopenMonth() {
setReopening(true);
try {
await reopenPeriod("month", month);
toast.success("Monat wieder geöffnet", {
description: `${month} kann wieder bearbeitet werden.`
});
await load();
} catch (error) {
toast.error("Monat konnte nicht geöffnet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setReopening(false);
}
}
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
const key = `${item.ticket_id}:${item.day}`;
setAcknowledgingKeys((current) => new Set(current).add(key));
try {
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingKeys((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{
label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
}
],
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
);
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<ChevronLeft className="size-4" />
</Button>
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{stats?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
<LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat öffnen"}
</Button>
) : (
<Button size="sm" disabled={!canClose || closing} onClick={finishMonth}>
<Lock className="size-4" />
{closing ? "Schließt..." : "Monat abschließen"}
</Button>
)}
</div>
</div>
{stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Checkliste</CardTitle>
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
</div>
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
{checklist.map((item) => (
<div key={item.label} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.label}</span>
<Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<Checkbox
checked={showAcknowledgedMissingCrm}
aria-label="Zur Kenntnis genommene Teamspace-Tage anzeigen"
onCheckedChange={(checked) => setShowAcknowledgedMissingCrm(checked === true)}
/>
<span>Zur Kenntnis anzeigen ({acknowledgedMissingCrmDays.length})</span>
</div>
<HelpLink anchor="monatsabschluss" label="Hilfe zu offenen Bewertungen und Teamspace-Prüfpunkten" />
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen
</div>
{openTicketGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</section>
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
</div>
{visibleMissingCrmDays.map((item) => (
<CrmDayItem
key={`${item.ticket_id}-${item.day}-missing`}
item={item}
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
onSetAcknowledgement={setMissingCrmAcknowledgement}
onNavigate={onNavigate}
/>
))}
{visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
</CardContent>
</Card>
{stats && stats.totals.sessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Clock3 className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
</div>
</CardContent>
</Card>
) : null}
</div>
);
}