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 (

{label}

{value}

{detail}

); } type OpenTicketGroup = { ticketId: string; ticketNumber: string; organizationName: string; openCount: number; totalMinutes: number; }; function groupOpenSessions(sessions: StatisticsOpenSession[]) { const groups = new Map(); 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 (
{group.ticketNumber} {group.openCount} offen

{group.organizationName}

{formatMinutes(group.totalMinutes)} offen zu bewerten

); } 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 (
{item.ticket_number} {formatDate(`${item.day}T00:00:00`)} {isAcknowledged ? "zur Kenntnis" : "offen"}

{item.organization_name}

Abgerechnet {formatMinutes(item.tracked_minutes)} Teamspace {formatMinutes(item.crm_billed_minutes)} {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null}

); } export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const [month, setMonth] = useState(currentMonth()); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [closing, setClosing] = useState(false); const [reopening, setReopening] = useState(false); const [acknowledgingKeys, setAcknowledgingKeys] = useState>(() => 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 (

Monatsabschluss

Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.

{loading ? lädt... : null}
setMonth(event.currentTarget.value)} />
{stats?.closed ? ( ) : ( )}
{stats?.closed ? ( Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. ) : null}
0 ? "warn" : "ok"} /> 0 ? "warn" : "ok"} />
Checkliste Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.
{checklist.map((item) => (
{item.label} {item.value}

{item.detail}

))}
Aufräumen Alles, was für diesen Monat noch Aufmerksamkeit braucht.
setShowAcknowledgedMissingCrm(checked === true)} /> Zur Kenntnis anzeigen ({acknowledgedMissingCrmDays.length})
Offene Bewertungen
{openTicketGroups.map((group) => ( ))} {openTicketGroups.length === 0 ?

Keine offenen Bewertungen.

: null}
Tage ohne Teamspace-Wert
{visibleMissingCrmDays.map((item) => ( ))} {visibleMissingCrmDays.length === 0 ? (

{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}

) : null}
{stats && stats.totals.sessions === 0 ? (

Keine Sessions in diesem Monat

Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.

) : null}
); }