Move month close workflow
This commit is contained in:
@@ -899,8 +899,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
AND s.started_at < $2::timestamptz
|
AND s.started_at < $2::timestamptz
|
||||||
AND s.user_id = $3
|
AND s.user_id = $3
|
||||||
AND s.billing_status IS NULL
|
AND s.billing_status IS NULL
|
||||||
ORDER BY s.started_at ASC
|
ORDER BY s.started_at ASC;
|
||||||
LIMIT 12;
|
|
||||||
`,
|
`,
|
||||||
[period.startIso, period.endIso, userId]
|
[period.startIso, period.endIso, userId]
|
||||||
),
|
),
|
||||||
@@ -918,8 +917,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
||||||
WHERE NOT tdb.has_crm_value
|
WHERE NOT tdb.has_crm_value
|
||||||
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
||||||
ORDER BY tdb.day ASC, tdb.ticket_number ASC
|
ORDER BY tdb.day ASC, tdb.ticket_number ASC;
|
||||||
LIMIT 12;
|
|
||||||
`,
|
`,
|
||||||
[period.startIso, period.endIso, userId]
|
[period.startIso, period.endIso, userId]
|
||||||
),
|
),
|
||||||
@@ -939,8 +937,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
WHERE tdb.has_crm_value
|
WHERE tdb.has_crm_value
|
||||||
AND tdb.tracked_minutes <> tdb.crm_billed_minutes
|
AND tdb.tracked_minutes <> tdb.crm_billed_minutes
|
||||||
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
||||||
ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC
|
ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC;
|
||||||
LIMIT 12;
|
|
||||||
`,
|
`,
|
||||||
[period.startIso, period.endIso, userId]
|
[period.startIso, period.endIso, userId]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ClipboardCheck,
|
||||||
Command,
|
Command,
|
||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -56,6 +57,7 @@ import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyF
|
|||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
import { LoginPage } from "./views/LoginPage";
|
import { LoginPage } from "./views/LoginPage";
|
||||||
|
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||||
import { ProfilePage } from "./views/ProfilePage";
|
import { ProfilePage } from "./views/ProfilePage";
|
||||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||||
import { StatisticsPage } from "./views/StatisticsPage";
|
import { StatisticsPage } from "./views/StatisticsPage";
|
||||||
@@ -95,6 +97,10 @@ function routeFromPath(pathname: string) {
|
|||||||
return { page: "statistics" as const };
|
return { page: "statistics" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/monthly-close")) {
|
||||||
|
return { page: "monthly-close" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/recurring")) {
|
if (pathname.startsWith("/recurring")) {
|
||||||
return { page: "recurring" as const };
|
return { page: "recurring" as const };
|
||||||
}
|
}
|
||||||
@@ -507,6 +513,7 @@ export function App() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||||
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||||
|
{ href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
|
||||||
{ href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
{ href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||||
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||||
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
@@ -595,7 +602,7 @@ export function App() {
|
|||||||
<CheckCircle2 className="size-4 text-muted-foreground" />
|
<CheckCircle2 className="size-4 text-muted-foreground" />
|
||||||
Abschluss
|
Abschluss
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">Offene Sessions findest du in Tages- und Monatsansicht.</p>
|
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -692,6 +699,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||||
|
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||||
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||||
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||||
|
|||||||
@@ -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 type { LucideIcon } from "lucide-react";
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Alert } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
|
import { getPeriodOverview } from "../api";
|
||||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
import { currentDay, currentMonth, formatMinutes } from "../format";
|
||||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||||
|
|
||||||
type AnalysisPageProps = {
|
type AnalysisPageProps = {
|
||||||
@@ -304,9 +303,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
const [period, setPeriod] = useState(currentMonth());
|
const [period, setPeriod] = useState(currentMonth());
|
||||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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 [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
@@ -362,7 +358,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
function switchPeriodType(nextType: PeriodType) {
|
function switchPeriodType(nextType: PeriodType) {
|
||||||
setPeriodType(nextType);
|
setPeriodType(nextType);
|
||||||
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
setPeriod(nextType === "month" ? currentMonth() : currentDay());
|
||||||
setShowOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePeriodBy(delta: number) {
|
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")}`);
|
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 totals = overview?.totals;
|
||||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||||
const supportsPeriodClosure = periodType === "month";
|
|
||||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||||
@@ -499,7 +456,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
|
<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"
|
className="h-9"
|
||||||
type={periodType === "month" ? "month" : "date"}
|
type={periodType === "month" ? "month" : "date"}
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(event) => {
|
onChange={(event) => setPeriod(event.currentTarget.value)}
|
||||||
setPeriod(event.currentTarget.value);
|
|
||||||
setShowOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
@@ -643,73 +597,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,13 @@ import {
|
|||||||
ArrowDownRight,
|
ArrowDownRight,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Clock3,
|
Clock3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileWarning,
|
|
||||||
ListChecks,
|
ListChecks,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Sparkles,
|
|
||||||
Ticket,
|
Ticket,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -20,16 +17,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Alert } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { getStatisticsOverview } from "../api";
|
import { getStatisticsOverview } from "../api";
|
||||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
import { currentMonth, formatDate, formatMinutes } from "../format";
|
||||||
import type { StatisticsCrmDay, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
|
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
|
||||||
|
|
||||||
type StatisticsPageProps = {
|
type StatisticsPageProps = {
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
@@ -41,6 +38,9 @@ const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
|
|||||||
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
|
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
|
||||||
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||||
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
|
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
|
||||||
|
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
|
||||||
|
const openTextClass = "text-amber-700 dark:text-amber-300";
|
||||||
|
const organizationPageSize = 8;
|
||||||
|
|
||||||
function workTypeLabel(workType: WorkType | undefined) {
|
function workTypeLabel(workType: WorkType | undefined) {
|
||||||
if (workType === "support") {
|
if (workType === "support") {
|
||||||
@@ -62,6 +62,32 @@ function percentage(value: number, total: number) {
|
|||||||
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundedPercentages(values: number[]) {
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
return values.map(() => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = values.map((value) => (value / total) * 100);
|
||||||
|
const floors = raw.map(Math.floor);
|
||||||
|
let remainder = 100 - floors.reduce((sum, value) => sum + value, 0);
|
||||||
|
const order = raw
|
||||||
|
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
||||||
|
.sort((left, right) => right.fraction - left.fraction);
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
if (remainder <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
floors[item.index] += 1;
|
||||||
|
remainder -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return floors;
|
||||||
|
}
|
||||||
|
|
||||||
function formatSignedMinutes(minutes: number) {
|
function formatSignedMinutes(minutes: number) {
|
||||||
if (minutes === 0) {
|
if (minutes === 0) {
|
||||||
return "ausgeglichen";
|
return "ausgeglichen";
|
||||||
@@ -86,6 +112,18 @@ function DeltaBadge({ minutes }: { minutes: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deltaDescription(minutes: number) {
|
||||||
|
if (minutes > 0) {
|
||||||
|
return "Teamspace liegt über den Sessions";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes < 0) {
|
||||||
|
return "Teamspace liegt unter den Sessions";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Teamspace und Sessions sind ausgeglichen";
|
||||||
|
}
|
||||||
|
|
||||||
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
@@ -96,6 +134,42 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teamspaceTrend(buckets: StatisticsDailyBucket[]) {
|
||||||
|
const activeBuckets = buckets
|
||||||
|
.filter((bucket) => bucket.total_minutes > 0 || bucket.crm_billed_minutes > 0)
|
||||||
|
.map((bucket) => ({
|
||||||
|
day: bucket.day,
|
||||||
|
deltaMinutes: bucket.crm_billed_minutes - bucket.total_minutes
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (activeBuckets.length < 2) {
|
||||||
|
return {
|
||||||
|
label: "Trend noch offen",
|
||||||
|
detail: "Zu wenige Tageswerte",
|
||||||
|
changeMinutes: 0,
|
||||||
|
firstAverage: 0,
|
||||||
|
secondAverage: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitIndex = Math.ceil(activeBuckets.length / 2);
|
||||||
|
const first = activeBuckets.slice(0, splitIndex);
|
||||||
|
const second = activeBuckets.slice(splitIndex);
|
||||||
|
const average = (items: typeof activeBuckets) => Math.round(items.reduce((sum, item) => sum + item.deltaMinutes, 0) / Math.max(items.length, 1));
|
||||||
|
const firstAverage = average(first);
|
||||||
|
const secondAverage = average(second.length > 0 ? second : first);
|
||||||
|
const changeMinutes = secondAverage - firstAverage;
|
||||||
|
const label = Math.abs(changeMinutes) < 15 ? "Trend stabil" : changeMinutes > 0 ? "Teamspace steigt relativ" : "Teamspace faellt relativ";
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
detail: `${formatTeamspaceDelta(firstAverage)} zu ${formatTeamspaceDelta(secondAverage)}`,
|
||||||
|
changeMinutes,
|
||||||
|
firstAverage,
|
||||||
|
secondAverage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatAxisMinutes(value: number) {
|
function formatAxisMinutes(value: number) {
|
||||||
const minutes = Math.round(value);
|
const minutes = Math.round(value);
|
||||||
|
|
||||||
@@ -254,14 +328,16 @@ function MetricCard({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
|
{detail ? <div className="text-xs text-muted-foreground">{detail}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrganizationRow({ organization, maxMinutes }: { organization: StatisticsGroup; maxMinutes: number }) {
|
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
|
||||||
|
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="max-w-[320px] whitespace-normal">
|
<TableCell className="max-w-[320px] whitespace-normal">
|
||||||
@@ -279,6 +355,7 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-[150px]">
|
<TableCell className="min-w-[150px]">
|
||||||
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -309,33 +386,11 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||||
const [month, setMonth] = useState(currentMonth());
|
const [month, setMonth] = useState(currentMonth());
|
||||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [organizationPage, setOrganizationPage] = useState(1);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -392,18 +447,31 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totals = stats?.totals;
|
const totals = stats?.totals;
|
||||||
const billableShare = percentage(totals?.billedMinutes ?? 0, totals?.minutes ?? 0);
|
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0);
|
||||||
const openShare = percentage(totals?.openMinutes ?? 0, totals?.minutes ?? 0);
|
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0);
|
||||||
|
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]);
|
||||||
|
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]);
|
||||||
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
||||||
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
||||||
const topOrganization = stats?.organizations[0] ?? null;
|
const topOrganization = stats?.organizations[0] ?? null;
|
||||||
const strongestDay = useMemo(() => {
|
const strongestDay = useMemo(() => {
|
||||||
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
|
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
|
||||||
}, [stats?.dailySeries]);
|
}, [stats?.dailySeries]);
|
||||||
const attentionCount =
|
const trend = useMemo(() => teamspaceTrend(stats?.dailySeries ?? []), [stats?.dailySeries]);
|
||||||
(stats?.attention.openSessions.length ?? 0) +
|
const organizationPageCount = Math.max(1, Math.ceil((stats?.organizations.length ?? 0) / organizationPageSize));
|
||||||
(stats?.attention.missingCrmDays.length ?? 0) +
|
const safeOrganizationPage = Math.min(organizationPage, organizationPageCount);
|
||||||
(stats?.attention.crmMismatches.length ?? 0);
|
const organizationStart = (safeOrganizationPage - 1) * organizationPageSize;
|
||||||
|
const visibleOrganizations = (stats?.organizations ?? []).slice(organizationStart, organizationStart + organizationPageSize);
|
||||||
|
const organizationRangeStart = stats?.organizations.length ? organizationStart + 1 : 0;
|
||||||
|
const organizationRangeEnd = Math.min(organizationStart + organizationPageSize, stats?.organizations.length ?? 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrganizationPage(1);
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrganizationPage((current) => Math.min(current, organizationPageCount));
|
||||||
|
}, [organizationPageCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -429,13 +497,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Getrackt"
|
label="Getrackt"
|
||||||
@@ -452,16 +513,28 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
valueClassName={teamspaceTextClass}
|
valueClassName={teamspaceTextClass}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Abgerechnet"
|
label="Bewertet"
|
||||||
value={formatMinutes(totals?.billedMinutes ?? 0)}
|
value={formatMinutes(evaluatedMinutes)}
|
||||||
detail={`${totals?.billedSessions ?? 0} Session(s), ${billableShare}% der Zeit`}
|
detail={
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
|
||||||
|
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
icon={ListChecks}
|
icon={ListChecks}
|
||||||
|
valueClassName={evaluatedTextClass}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Offen"
|
label="Offen"
|
||||||
value={formatMinutes(totals?.openMinutes ?? 0)}
|
value={formatMinutes(totals?.openMinutes ?? 0)}
|
||||||
detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}% der Zeit`}
|
detail={
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
|
||||||
|
<div>{openTimeShare}% der Zeit</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
icon={CircleAlert}
|
icon={CircleAlert}
|
||||||
|
valueClassName={openTextClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -493,23 +566,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle>Abrechnungsqualität</CardTitle>
|
<CardTitle>Abrechnungsqualität</CardTitle>
|
||||||
<CardDescription>Bewertung, CRM-Abdeckung und Erfassungsart.</CardDescription>
|
<CardDescription>Bewertungsstand, Teamspace-Abgleich und Entwicklung im Monat.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 px-4 pb-4">
|
<CardContent className="space-y-4 px-4 pb-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span className="text-muted-foreground">Abgerechnet</span>
|
<span className="text-muted-foreground">Bewertungsstand</span>
|
||||||
<span className="font-medium">{billableShare}%</span>
|
<span className="font-medium">{evaluatedSessionShare}% / {openSessionShare}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={billableShare} />
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
|
||||||
|
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
|
||||||
<span className="text-muted-foreground">Teamspace</span>
|
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div>
|
||||||
<span className="font-medium">{crmCoverage}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={crmCoverage} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Teamspace-Abgleich</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||||
|
</div>
|
||||||
|
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
|
||||||
|
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{trend.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{trend.detail}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={Math.abs(trend.changeMinutes) < 15 ? "outline" : "warning"}>
|
||||||
|
{formatSignedMinutes(trend.changeMinutes)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
<p className="text-muted-foreground">Manuell</p>
|
<p className="text-muted-foreground">Manuell</p>
|
||||||
@@ -546,9 +644,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
|
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
<CardTitle>Statistik pro Organisation</CardTitle>
|
<CardTitle>Statistik pro Organisation</CardTitle>
|
||||||
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz.</CardDescription>
|
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{organizationRangeStart}-{organizationRangeEnd} von {stats?.organizations.length ?? 0}
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
@@ -559,18 +662,23 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<TableHead>Sessions</TableHead>
|
<TableHead>Sessions</TableHead>
|
||||||
<TableHead>Zeiten</TableHead>
|
<TableHead>Zeiten</TableHead>
|
||||||
<TableHead>TS-Differenz</TableHead>
|
<TableHead>TS-Differenz</TableHead>
|
||||||
<TableHead>Anteil</TableHead>
|
<TableHead>Anteil am Monat</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(stats?.organizations ?? []).map((organization) => (
|
{visibleOrganizations.map((organization) => (
|
||||||
<OrganizationRow key={organization.organization_id ?? organization.organization_name} organization={organization} maxMinutes={maxOrganizationMinutes} />
|
<OrganizationRow
|
||||||
|
key={organization.organization_id ?? organization.organization_name}
|
||||||
|
organization={organization}
|
||||||
|
maxMinutes={maxOrganizationMinutes}
|
||||||
|
totalMinutes={totals?.minutes ?? 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:hidden">
|
<div className="space-y-2 md:hidden">
|
||||||
{(stats?.organizations ?? []).map((organization) => (
|
{visibleOrganizations.map((organization) => (
|
||||||
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
|
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -583,11 +691,47 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
|
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||||
|
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
||||||
<span className="text-muted-foreground">{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
|
<span className="text-muted-foreground">{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{(stats?.organizations.length ?? 0) > organizationPageSize ? (
|
||||||
|
<Pagination className="mt-4 justify-end">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
text="Zurück"
|
||||||
|
aria-disabled={safeOrganizationPage <= 1}
|
||||||
|
className={safeOrganizationPage <= 1 ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setOrganizationPage((current) => Math.max(1, current - 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
|
||||||
|
Seite {safeOrganizationPage} von {organizationPageCount}
|
||||||
|
</span>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
text="Weiter"
|
||||||
|
aria-disabled={safeOrganizationPage >= organizationPageCount}
|
||||||
|
className={safeOrganizationPage >= organizationPageCount ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setOrganizationPage((current) => Math.min(organizationPageCount, current + 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
|
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -624,7 +768,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
@@ -644,61 +787,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Aufräumen</CardTitle>
|
|
||||||
<CardDescription>Alles, was vor dem Monatsabschluss Aufmerksamkeit braucht.</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge variant={attentionCount > 0 ? "warning" : "success"}>{attentionCount} Punkt(e)</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 px-4 pb-4">
|
|
||||||
<div 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>
|
|
||||||
{(stats?.attention.openSessions ?? []).slice(0, 5).map((session) => (
|
|
||||||
<div key={session.id} className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium">{session.ticket_number}</p>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{session.organization_name}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{session.activity}</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
|
|
||||||
Öffnen
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{stats?.attention.openSessions.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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>
|
|
||||||
{(stats?.attention.missingCrmDays ?? []).slice(0, 4).map((item) => (
|
|
||||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-missing`} item={item} onNavigate={onNavigate} />
|
|
||||||
))}
|
|
||||||
{stats?.attention.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}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<ArrowUpRight className="size-4 text-muted-foreground" />
|
|
||||||
Auffällige Differenzen
|
|
||||||
</div>
|
|
||||||
{(stats?.attention.crmMismatches ?? []).slice(0, 4).map((item) => (
|
|
||||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-mismatch`} item={item} onNavigate={onNavigate} />
|
|
||||||
))}
|
|
||||||
{stats?.attention.crmMismatches.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine Abweichungen zwischen Tracking und Teamspace.</p> : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-3">
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user