370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||
import { Query } from 'appwrite';
|
||
import Header from './Header';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useAuth } from '@/context/AuthContext';
|
||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
ChartContainer,
|
||
ChartTooltip,
|
||
ChartTooltipContent,
|
||
ChartLegend,
|
||
ChartLegendContent,
|
||
} from '@/components/ui/chart';
|
||
|
||
function getToday() {
|
||
const d = new Date();
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
}
|
||
|
||
function getMonthStart() {
|
||
const d = new Date();
|
||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
function getLastMonthStart() {
|
||
const d = new Date();
|
||
return new Date(d.getFullYear(), d.getMonth() - 1, 1);
|
||
}
|
||
|
||
function getLastMonthEnd() {
|
||
const d = new Date();
|
||
return new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59);
|
||
}
|
||
|
||
function getYesterday() {
|
||
const d = getToday();
|
||
d.setDate(d.getDate() - 1);
|
||
return d;
|
||
}
|
||
|
||
function countInRange(assets, start, end) {
|
||
return assets.filter((a) => {
|
||
const d = new Date(a.$createdAt);
|
||
return d >= start && d <= end;
|
||
}).length;
|
||
}
|
||
|
||
function countErledigtInRange(assets, start, end) {
|
||
return assets.filter((a) => {
|
||
if (a.status !== 'entsorgt') return false;
|
||
const d = new Date(a.$updatedAt || a.$createdAt);
|
||
return d >= start && d <= end;
|
||
}).length;
|
||
}
|
||
|
||
function countUeberfaelligAt(assets, endOfPeriod) {
|
||
const cutoff = new Date(endOfPeriod);
|
||
cutoff.setDate(cutoff.getDate() - 7);
|
||
return assets.filter((a) => {
|
||
const created = new Date(a.$createdAt);
|
||
if (created > cutoff) return false;
|
||
if (a.status === 'entsorgt') {
|
||
const disposed = new Date(a.$updatedAt || a.$createdAt);
|
||
return disposed > endOfPeriod;
|
||
}
|
||
return true;
|
||
}).length;
|
||
}
|
||
|
||
export default function FilialleiterDashboard() {
|
||
const navigate = useNavigate();
|
||
const { userMeta } = useAuth();
|
||
const { showToast } = useToast();
|
||
const locationId = userMeta?.locationId || '';
|
||
|
||
const [ownAssets, setOwnAssets] = useState([]);
|
||
const [allAssetsTotal, setAllAssetsTotal] = useState(0);
|
||
const [allLocationsCount, setAllLocationsCount] = useState(1);
|
||
const [colleagues, setColleagues] = useState([]);
|
||
|
||
const loadData = useCallback(async () => {
|
||
if (!locationId) return;
|
||
try {
|
||
const [assetsRes, lagerRes, metaRes, locsRes] = await Promise.all([
|
||
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(500)]),
|
||
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [
|
||
Query.equal('locationId', [locationId]),
|
||
Query.limit(200),
|
||
]),
|
||
databases.listDocuments(DATABASE_ID, 'users_meta', [
|
||
Query.equal('locationId', [locationId]),
|
||
Query.limit(100),
|
||
]),
|
||
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
|
||
]);
|
||
const lagerIds = new Set(lagerRes.documents.map((l) => l.$id));
|
||
const assetsForLocation = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId));
|
||
setOwnAssets(assetsForLocation);
|
||
setAllAssetsTotal(assetsRes.total);
|
||
setAllLocationsCount(Math.max(locsRes.total, 1));
|
||
setColleagues(metaRes.documents.filter((d) => d.userName));
|
||
} catch (err) {
|
||
console.error('Filialleiter-Daten laden fehlgeschlagen:', err);
|
||
}
|
||
}, [locationId]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
|
||
const now = new Date();
|
||
const today = getToday();
|
||
const yesterday = getYesterday();
|
||
const monthStart = getMonthStart();
|
||
const lastMonthStart = getLastMonthStart();
|
||
const lastMonthEnd = getLastMonthEnd();
|
||
|
||
const todayCount = countInRange(ownAssets, today, now);
|
||
const yesterdayCount = countInRange(ownAssets, yesterday, today);
|
||
const thisMonthCount = countInRange(ownAssets, monthStart, now);
|
||
const lastMonthCount = countInRange(ownAssets, lastMonthStart, lastMonthEnd);
|
||
|
||
const chartData = useMemo(() => {
|
||
const days = [];
|
||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() - i);
|
||
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59);
|
||
days.push({
|
||
day: `${dayNames[d.getDay()]} ${d.getDate()}.`,
|
||
erfasst: countInRange(ownAssets, dayStart, dayEnd),
|
||
erledigt: countErledigtInRange(ownAssets, dayStart, dayEnd),
|
||
ueberfaellig: countUeberfaelligAt(ownAssets, dayEnd),
|
||
});
|
||
}
|
||
return days;
|
||
}, [ownAssets]);
|
||
|
||
const monthChartData = useMemo(() => {
|
||
const months = [];
|
||
const monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||
for (let i = 5; i >= 0; i--) {
|
||
const d = new Date();
|
||
d.setMonth(d.getMonth() - i);
|
||
const monthStart = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
const monthEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59);
|
||
months.push({
|
||
month: `${monthNames[d.getMonth()]} ${d.getFullYear().toString().slice(2)}`,
|
||
erfasst: countInRange(ownAssets, monthStart, monthEnd),
|
||
erledigt: countErledigtInRange(ownAssets, monthStart, monthEnd),
|
||
ueberfaellig: countUeberfaelligAt(ownAssets, monthEnd),
|
||
});
|
||
}
|
||
return months;
|
||
}, [ownAssets]);
|
||
|
||
const chartConfig = {
|
||
erfasst: {
|
||
label: 'Erfasst',
|
||
color: '#60a5fa',
|
||
},
|
||
erledigt: {
|
||
label: 'Erledigt',
|
||
color: '#22c55e',
|
||
},
|
||
ueberfaellig: {
|
||
label: 'Überfällig',
|
||
color: '#ef4444',
|
||
},
|
||
};
|
||
|
||
const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssetsTotal / allLocationsCount) : 0;
|
||
const ownTotal = ownAssets.length;
|
||
|
||
const employeeStats = useMemo(() => {
|
||
return colleagues.map((c) => {
|
||
const assigned = ownAssets.filter((a) => a.zustaendig === c.userName);
|
||
const resolved = assigned.filter((a) => a.status === 'entsorgt').length;
|
||
const open = assigned.filter((a) => a.status === 'offen').length;
|
||
const inProgress = assigned.filter((a) => a.status === 'in_bearbeitung').length;
|
||
return {
|
||
userId: c.userId,
|
||
name: c.userName,
|
||
total: assigned.length,
|
||
resolved,
|
||
open,
|
||
inProgress,
|
||
rate: assigned.length > 0 ? Math.round((resolved / assigned.length) * 100) : 0,
|
||
};
|
||
}).sort((a, b) => b.rate - a.rate);
|
||
}, [colleagues, ownAssets]);
|
||
|
||
function trendArrow(current, previous) {
|
||
if (current > previous) return { arrow: '▲', cls: 'text-green-600' };
|
||
if (current < previous) return { arrow: '▼', cls: 'text-red-600' };
|
||
return { arrow: '–', cls: 'text-muted-foreground' };
|
||
}
|
||
|
||
const dayTrend = trendArrow(todayCount, yesterdayCount);
|
||
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
|
||
|
||
const comparisonMax = Math.max(ownTotal, avgAllFilialen, 1);
|
||
|
||
return (
|
||
<>
|
||
<Header showToast={showToast} />
|
||
<div className="mx-auto max-w-7xl p-6">
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold tracking-tight">Filialleiter Dashboard</h1>
|
||
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
||
</div>
|
||
|
||
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<Card className="lg:col-span-2">
|
||
<CardContent className="pt-4">
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<div>
|
||
<div className="text-3xl font-bold">{todayCount}</div>
|
||
<p className="text-sm text-muted-foreground">Heute erfasst</p>
|
||
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
|
||
{dayTrend.arrow} Gestern: {yesterdayCount}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-[140px] w-full">
|
||
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
||
<BarChart
|
||
data={chartData}
|
||
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
||
>
|
||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||
<XAxis
|
||
axisLine={false}
|
||
dataKey="day"
|
||
tickLine={false}
|
||
tickMargin={6}
|
||
/>
|
||
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||
<ChartLegend content={<ChartLegendContent />} />
|
||
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
||
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
||
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="lg:col-span-2">
|
||
<CardContent className="pt-4">
|
||
<div>
|
||
<div className="text-3xl font-bold">{thisMonthCount}</div>
|
||
<p className="text-sm text-muted-foreground">Diesen Monat</p>
|
||
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
|
||
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
|
||
</p>
|
||
</div>
|
||
<div className="mt-4 h-[140px] w-full">
|
||
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
|
||
<BarChart
|
||
data={monthChartData}
|
||
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
|
||
>
|
||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||
<XAxis
|
||
axisLine={false}
|
||
dataKey="month"
|
||
tickLine={false}
|
||
tickMargin={6}
|
||
/>
|
||
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
|
||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||
<ChartLegend content={<ChartLegendContent />} />
|
||
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
|
||
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
|
||
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-2">
|
||
<div className="text-3xl font-bold">{ownTotal}</div>
|
||
<p className="text-sm text-muted-foreground">Meine Filiale</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-2">
|
||
<div className="text-3xl font-bold">{avgAllFilialen}</div>
|
||
<p className="text-sm text-muted-foreground">⌀ Alle Filialen</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle>Filialvergleich</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-4">
|
||
<span className="w-32 shrink-0 text-sm font-medium">Meine Filiale</span>
|
||
<Progress value={Math.round((ownTotal / comparisonMax) * 100)} className="flex-1" />
|
||
<span className="w-12 text-right text-sm font-semibold tabular-nums">{ownTotal}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className="w-32 shrink-0 text-sm font-medium">⌀ Durchschnitt</span>
|
||
<Progress value={Math.round((avgAllFilialen / comparisonMax) * 100)} className="flex-1" />
|
||
<span className="w-12 text-right text-sm font-semibold tabular-nums">{avgAllFilialen}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Mitarbeiter-Performance</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{employeeStats.length === 0 ? (
|
||
<p className="py-4 text-center text-sm text-muted-foreground">Keine Mitarbeiter gefunden</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Mitarbeiter</TableHead>
|
||
<TableHead className="text-right">Zugewiesen</TableHead>
|
||
<TableHead className="text-right">Offen</TableHead>
|
||
<TableHead className="text-right">In Bearbeitung</TableHead>
|
||
<TableHead className="text-right">Erledigt</TableHead>
|
||
<TableHead className="w-48">Erledigungsrate</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{employeeStats.map((e) => (
|
||
<TableRow
|
||
key={e.userId || e.name}
|
||
className="cursor-pointer hover:bg-muted/50"
|
||
onClick={() => e.userId && navigate(`/filialleiter/mitarbeiter/${e.userId}`)}
|
||
>
|
||
<TableCell className="font-medium">{e.name}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{e.total}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{e.open}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{e.inProgress}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{e.resolved}</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2">
|
||
<Progress value={e.rate} className="flex-1" />
|
||
<span className="w-10 text-right text-xs font-medium tabular-nums">{e.rate}%</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|