Files
assetsTracker/src/components/FilialleiterDashboard.jsx
2026-03-19 21:13:55 +01:00

370 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}