Move month close workflow

This commit is contained in:
2026-08-05 15:05:25 +02:00
parent 4b35dfe55e
commit ca5e015065
5 changed files with 578 additions and 260 deletions
+6 -111
View File
@@ -1,8 +1,7 @@
import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Fragment, 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";
@@ -10,8 +9,8 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import { getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = {
@@ -304,9 +303,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null);
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [showOpen, setShowOpen] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0);
@@ -362,7 +358,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
function switchPeriodType(nextType: PeriodType) {
setPeriodType(nextType);
setPeriod(nextType === "month" ? currentMonth() : currentDay());
setShowOpen(false);
}
function changePeriodBy(delta: number) {
@@ -381,48 +376,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
}
setShowOpen(false);
}
async function finishPeriod() {
setClosing(true);
try {
await closePeriod(periodType, period);
toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, {
description: `${period} ist abgeschlossen.`
});
await load();
} catch (error) {
toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, {
description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen."
});
setShowOpen(true);
await load();
} finally {
setClosing(false);
}
}
async function openPeriodAgain() {
setReopening(true);
try {
await reopenPeriod(periodType, period);
toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, {
description: `${period} kann wieder bearbeitet werden.`
});
await load();
} catch (error) {
toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setReopening(false);
}
}
const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const supportsPeriodClosure = periodType === "month";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
@@ -499,7 +456,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div>
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
<p className="text-sm text-muted-foreground">
{supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."}
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
@@ -527,10 +484,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
className="h-9"
type={periodType === "month" ? "month" : "date"}
value={period}
onChange={(event) => {
setPeriod(event.currentTarget.value);
setShowOpen(false);
}}
onChange={(event) => setPeriod(event.currentTarget.value)}
/>
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
<ChevronRight className="size-4" />
@@ -643,73 +597,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</CardContent>
</Card>
{supportsPeriodClosure && overview?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.</span>
</Alert>
) : null}
{overview && overview.openSessions.length > 0 ? (
<Alert variant="warning" className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<CircleAlert className="size-4 shrink-0" />
<span>Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.</span>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowOpen((value) => !value)}>
Offene Sessions prüfen
</Button>
</Alert>
) : null}
{showOpen && overview?.openSessions.length ? (
<Card>
<CardHeader className="p-4">
<CardTitle>Nicht bearbeitete Sessions</CardTitle>
<CardDescription>Diese Einträge blockieren den Monatsabschluss.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{overview.openSessions.map((session) => (
<div key={session.id} className="grid gap-2 rounded-md border 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>
</div>
<p className="truncate text-sm text-muted-foreground">{session.customer_name}</p>
<p className="text-sm">{session.activity}</p>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
))}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Tickets im Zeitraum</CardTitle>
<CardDescription>
{supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."}
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
</CardDescription>
</div>
{supportsPeriodClosure ? (
overview?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={openPeriodAgain}>
<LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat wieder öffnen"}
</Button>
) : (
<Button size="sm" variant={overview?.canClose ? "default" : "secondary"} disabled={!overview?.canClose || closing} onClick={finishPeriod}>
<CheckCircle2 className="size-4" />
{closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
</Button>
)
) : null}
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="mb-4 rounded-md border bg-muted/20 p-3">