Move month close workflow
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
FileWarning,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} 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 { Input } from "@/components/ui/input";
|
||||
import { 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";
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
return "ausgeglichen";
|
||||
}
|
||||
|
||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||
}
|
||||
|
||||
function formatTeamspaceDelta(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
return "ausgeglichen";
|
||||
}
|
||||
|
||||
return `TS ${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>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenSessionItem({ session, month, onNavigate }: { session: StatisticsOpenSession; month: string; onNavigate: (to: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{session.ticket_number}</span>
|
||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
||||
<Badge variant="warning">offen</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{session.organization_name}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{session.activity}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
|
||||
Bewerten
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{item.ticket_number}</span>
|
||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</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}>Sessions {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>
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
||||
Tag öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</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 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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const openSessions = stats?.attention.openSessions ?? [];
|
||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||
const crmMismatches = stats?.attention.crmMismatches ?? [];
|
||||
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const checklist = useMemo(
|
||||
() => [
|
||||
{ label: "Offene Bewertungen", count: openSessions.length, blocker: true },
|
||||
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, blocker: false },
|
||||
{ label: "Teamspace-Differenzen", count: crmMismatches.length, blocker: false }
|
||||
],
|
||||
[openSessions.length, missingCrmDays.length, crmMismatches.length]
|
||||
);
|
||||
|
||||
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>
|
||||
) : openSessions.length > 0 ? (
|
||||
<Alert variant="warning" className="flex items-center gap-2 py-3">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>{openSessions.length} offene Bewertung(en) blockieren den Monatsabschluss.</span>
|
||||
</Alert>
|
||||
) : missingCrmDays.length > 0 || crmMismatches.length > 0 ? (
|
||||
<Alert variant="warning" className="flex items-center gap-2 py-3">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>Der Monat ist abschließbar, hat aber noch Teamspace-Prüfpunkte.</span>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} />
|
||||
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
|
||||
<StatusCard label="Offen" value={openSessions.length} detail="blockiert den Abschluss" tone={openSessions.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">
|
||||
<CardTitle>Checkliste</CardTitle>
|
||||
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
|
||||
</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.count > 0 ? "warning" : "success"}>{item.count}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : "Vor Abschluss prüfen."}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Aufräumen</CardTitle>
|
||||
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-4 pb-4">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileWarning className="size-4 text-muted-foreground" />
|
||||
Offene Bewertungen
|
||||
</div>
|
||||
{openSessions.map((session) => (
|
||||
<OpenSessionItem key={session.id} session={session} month={month} onNavigate={onNavigate} />
|
||||
))}
|
||||
{openSessions.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 items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
Tage ohne Teamspace-Wert
|
||||
</div>
|
||||
{missingCrmDays.map((item) => (
|
||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-missing`} item={item} onNavigate={onNavigate} />
|
||||
))}
|
||||
{missingCrmDays.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Alle getrackten Tage haben einen Teamspace-Wert.</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<TrendingUp className="size-4 text-muted-foreground" />
|
||||
Teamspace-Differenzen
|
||||
</div>
|
||||
{crmMismatches.map((item) => (
|
||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-mismatch`} item={item} onNavigate={onNavigate} />
|
||||
))}
|
||||
{crmMismatches.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine Abweichungen zwischen Sessions und Teamspace.</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user