fieles neues
This commit is contained in:
@@ -1,20 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import Header from './Header';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import FilialDetail from './FilialDetail';
|
||||
import UserCreateForm from './UserCreateForm';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
admin: 'Admin',
|
||||
firmenleiter: 'Firmenleiter',
|
||||
filialleiter: 'Filialleiter',
|
||||
service: 'Service',
|
||||
lager: 'Lager',
|
||||
};
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stats, setStats] = useState({ users: 0, locations: 0, assets: 0, lagerstandorte: 0, locationsWithoutFilialleiter: 0 });
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [userSearchQuery, setUserSearchQuery] = useState('');
|
||||
const [showUserForm, setShowUserForm] = useState(false);
|
||||
const [newFiliale, setNewFiliale] = useState({ name: '', address: '' });
|
||||
const [addingFiliale, setAddingFiliale] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
@@ -24,11 +39,12 @@ export default function AdminPanel() {
|
||||
try {
|
||||
const [locsRes, usersRes, assetsRes, lsRes] = await Promise.all([
|
||||
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
|
||||
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(200)]),
|
||||
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(500)]),
|
||||
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [Query.limit(1)]),
|
||||
]);
|
||||
setLocations(locsRes.documents);
|
||||
setUsers(usersRes.documents);
|
||||
const locationsWithoutFilialleiter = locsRes.documents.filter(
|
||||
(loc) => !usersRes.documents.some((u) => u.locationId === loc.$id && u.role === 'filialleiter')
|
||||
).length;
|
||||
@@ -115,6 +131,19 @@ export default function AdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
!userSearchQuery.trim() ||
|
||||
(u.userName || '').toLowerCase().includes(userSearchQuery.toLowerCase()) ||
|
||||
(u.userId || '').toLowerCase().includes(userSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getLocationName = (locationId) => {
|
||||
if (!locationId) return 'Nicht zugeordnet';
|
||||
const loc = locations.find((l) => l.$id === locationId);
|
||||
return loc?.name || 'Unbekannte Filiale';
|
||||
};
|
||||
|
||||
const statItems = [
|
||||
{ label: 'Benutzer', value: stats.users },
|
||||
{ label: 'Filialen', value: stats.locations },
|
||||
@@ -226,6 +255,70 @@ export default function AdminPanel() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Benutzer verwaltung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Input
|
||||
placeholder="Benutzer suchen..."
|
||||
value={userSearchQuery}
|
||||
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowUserForm(true)}>
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-[280px] rounded-lg border">
|
||||
<div className="space-y-1 p-2">
|
||||
{users
|
||||
.filter((u) =>
|
||||
(u.userName || u.userId || '')
|
||||
.toLowerCase()
|
||||
.includes(userSearchQuery.toLowerCase())
|
||||
)
|
||||
.map((u) => (
|
||||
<button
|
||||
key={u.$id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/user/${u.userId}`)}
|
||||
className="flex w-full items-center justify-between rounded border px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">{u.userName || u.userId}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{locations.find((l) => l.$id === u.locationId)?.name || 'Nicht zugeordnet'}
|
||||
</span>
|
||||
<Badge variant="secondary">{ROLE_LABELS[u.role] || u.role}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{users.filter((u) =>
|
||||
(u.userName || u.userId || '').toLowerCase().includes(userSearchQuery.toLowerCase())
|
||||
).length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{userSearchQuery ? 'Keine Benutzer gefunden' : 'Keine Benutzer vorhanden'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showUserForm && (
|
||||
<UserCreateForm
|
||||
locations={locations}
|
||||
onSuccess={() => {
|
||||
setShowUserForm(false);
|
||||
loadData();
|
||||
}}
|
||||
onCancel={() => setShowUserForm(false)}
|
||||
showToast={showToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { isOverdue } from '../hooks/useAssets';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import LagerstandortManager from './LagerstandortManager';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
const STAT_CARDS = [
|
||||
@@ -11,9 +8,7 @@ const STAT_CARDS = [
|
||||
{ key: 'overdue', color: '#2563EB', label: 'Überfällig (>7 Tage)' },
|
||||
];
|
||||
|
||||
export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, onToggleLagerstandort, onDeleteLagerstandort }) {
|
||||
const { isAdmin, isFilialleiter } = useAuth();
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
export default function Dashboard({ assets, statusFilter, onStatusFilterChange }) {
|
||||
|
||||
const counts = {
|
||||
offen: assets.filter((a) => a.status === 'offen').length,
|
||||
@@ -22,41 +17,33 @@ export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort,
|
||||
overdue: assets.filter(isOverdue).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
{STAT_CARDS.map(({ key, color, label }) => (
|
||||
<Card key={key} className="py-0" style={{ borderTop: `3px solid ${color}` }}>
|
||||
<CardContent className="py-5">
|
||||
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
const handleCardClick = (key) => {
|
||||
onStatusFilterChange?.(statusFilter === key ? null : key);
|
||||
};
|
||||
|
||||
{(isAdmin || isFilialleiter) && (
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{STAT_CARDS.map(({ key, color, label }) => {
|
||||
const isSelected = statusFilter === key;
|
||||
return (
|
||||
<Card
|
||||
className="py-0 cursor-pointer transition-colors hover:bg-muted/50"
|
||||
style={{ borderTop: '3px solid #F57C00' }}
|
||||
onClick={() => setShowManager(true)}
|
||||
key={key}
|
||||
className="py-0 cursor-pointer transition-all duration-200 hover:opacity-90"
|
||||
style={{
|
||||
borderTop: `3px solid ${color}`,
|
||||
...(isSelected && {
|
||||
backgroundColor: `${color}30`,
|
||||
}),
|
||||
}}
|
||||
onClick={() => handleCardClick(key)}
|
||||
>
|
||||
<CardContent className="py-5">
|
||||
<div className="text-3xl font-bold tracking-tight">{lagerstandorte.length}</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Lagerstandorte verwalten</p>
|
||||
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
|
||||
<p className={`text-sm mt-1 ${isSelected ? 'text-foreground/90' : 'text-muted-foreground'}`}>{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showManager && (
|
||||
<LagerstandortManager
|
||||
lagerstandorte={lagerstandorte}
|
||||
onAdd={onAddLagerstandort}
|
||||
onToggle={onToggleLagerstandort}
|
||||
onDelete={onDeleteLagerstandort}
|
||||
onClose={() => setShowManager(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-none">
|
||||
<CardHeader className="px-0 pt-0">
|
||||
<CardHeader className="px-4 pt-0 pb-2">
|
||||
<CardTitle>Defekte Ware erfassen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<CardContent className="px-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import CommentPopup from './CommentPopup';
|
||||
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +19,14 @@ const PRIO_COLORS = {
|
||||
niedrig: 'bg-green-500',
|
||||
};
|
||||
|
||||
/** Prioritätsfarbe füllt die komplette ERL-Nr.-Zelle als Hintergrund */
|
||||
const PRIO_ERL_CELL = {
|
||||
kritisch: '!bg-red-600 text-red-50',
|
||||
hoch: '!bg-orange-500 text-orange-50',
|
||||
mittel: '!bg-yellow-400 text-yellow-950',
|
||||
niedrig: '!bg-green-500 text-green-50',
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'prio', label: 'Priorität' },
|
||||
{ value: 'newest', label: 'Neueste zuerst' },
|
||||
@@ -27,12 +34,6 @@ const SORT_OPTIONS = [
|
||||
{ value: 'mine', label: 'Mir zugewiesen' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'offen', label: 'Offen' },
|
||||
{ value: 'in_bearbeitung', label: 'In Bearbeitung' },
|
||||
{ value: 'entsorgt', label: 'Entsorgt' },
|
||||
];
|
||||
|
||||
const STATUS_BADGE_CONFIG = {
|
||||
offen: { variant: 'destructive' },
|
||||
in_bearbeitung: { variant: 'default', className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
@@ -51,7 +52,9 @@ function resolveStandortName(asset, lagerstandorte) {
|
||||
return ls ? ls.name : '–';
|
||||
}
|
||||
|
||||
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte }) {
|
||||
const STATUS_FILTER_MAP = { offen: 'offen', bearbeitung: 'in_bearbeitung', entsorgt: 'entsorgt' };
|
||||
|
||||
export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte, statusFilter }) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeFilter, setActiveFilter] = useState(null);
|
||||
@@ -63,7 +66,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
seriennummer: '',
|
||||
defekt: '',
|
||||
standort: '',
|
||||
status: '',
|
||||
sortBy: 'prio',
|
||||
});
|
||||
|
||||
@@ -90,7 +92,11 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
if (filters.seriennummer && !(a.seriennummer || '').toLowerCase().includes(filters.seriennummer.toLowerCase())) return false;
|
||||
if (filters.defekt && !(a.defekt || '').toLowerCase().includes(filters.defekt.toLowerCase())) return false;
|
||||
if (filters.standort && a.lagerstandortId !== filters.standort) return false;
|
||||
if (filters.status && a.status !== filters.status) return false;
|
||||
if (statusFilter === 'overdue') {
|
||||
if (!isOverdue(a)) return false;
|
||||
} else if (statusFilter && STATUS_FILTER_MAP[statusFilter]) {
|
||||
if (a.status !== STATUS_FILTER_MAP[statusFilter]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -113,7 +119,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [assets, filters, user]);
|
||||
}, [assets, filters, user, statusFilter]);
|
||||
|
||||
function handlePrint() {
|
||||
const printable = filtered.filter((a) => a.status === 'offen' || a.status === 'in_bearbeitung');
|
||||
@@ -185,7 +191,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
|
||||
|
||||
return (
|
||||
<Card className="py-0 gap-0">
|
||||
<div className="w-full rounded-md border bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{filtered.length} Assets
|
||||
@@ -219,10 +225,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Sortierung" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
@@ -232,23 +234,26 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
{filtered.map((a, index) => {
|
||||
const days = getDaysOld(a.$createdAt);
|
||||
const overdue = isOverdue(a);
|
||||
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
|
||||
const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
|
||||
const statusBtnCfg = STATUS_BUTTON_CONFIG[a.status] || STATUS_BUTTON_CONFIG.offen;
|
||||
|
||||
const rowClassName = overdue
|
||||
? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20'
|
||||
: index % 2 === 0
|
||||
? 'bg-muted/50'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={a.$id}
|
||||
className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
|
||||
className={rowClassName}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || '–'}</span>
|
||||
</div>
|
||||
<TableCell className={`font-semibold p-2 ${PRIO_ERL_CELL[a.prio] || 'bg-muted/50 text-foreground'}`}>
|
||||
{a.erlNummer || '–'}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
@@ -324,6 +329,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
{commentAsset && (
|
||||
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import Header from './Header';
|
||||
import Dashboard from './Dashboard';
|
||||
import DefektForm from './DefektForm';
|
||||
@@ -13,13 +13,14 @@ import { useAuth } from '../context/AuthContext';
|
||||
export default function DefektTrackApp() {
|
||||
const { user, userMeta } = useAuth();
|
||||
const locationId = userMeta?.locationId || '';
|
||||
const { assets, addAsset, changeStatus } = useAssets();
|
||||
const { assets, addAsset, changeStatus } = useAssets(locationId);
|
||||
const { addLog } = useAuditLog();
|
||||
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
|
||||
const { colleagues } = useColleagues(locationId);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const userName = user?.name || user?.email || 'Unbekannt';
|
||||
const [statusFilter, setStatusFilter] = useState(null);
|
||||
|
||||
const handleAdd = useCallback(async (data) => {
|
||||
const doc = await addAsset({ ...data, createdBy: userName, lastEditedBy: userName });
|
||||
@@ -74,10 +75,8 @@ export default function DefektTrackApp() {
|
||||
<div className="p-4">
|
||||
<Dashboard
|
||||
assets={assets}
|
||||
lagerstandorte={lagerstandorte}
|
||||
onAddLagerstandort={addLagerstandort}
|
||||
onToggleLagerstandort={toggleLagerstandort}
|
||||
onDeleteLagerstandort={deleteLagerstandort}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +86,7 @@ export default function DefektTrackApp() {
|
||||
onChangeStatus={handleStatusChange}
|
||||
showToast={showToast}
|
||||
lagerstandorte={lagerstandorte}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
|
||||
import LagerstandortManager from './LagerstandortManager';
|
||||
import UserAssignDialog from './UserAssignDialog';
|
||||
import UserCreateForm from './UserCreateForm';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +21,7 @@ const ROLE_LABELS = {
|
||||
|
||||
export default function FilialDetail({ location: loc, onClose, showToast, onUserAdded }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showAssignDialog, setShowAssignDialog] = useState(false);
|
||||
const [showUserForm, setShowUserForm] = useState(false);
|
||||
const [showLsManager, setShowLsManager] = useState(false);
|
||||
|
||||
@@ -91,9 +93,14 @@ export default function FilialDetail({ location: loc, onClose, showToast, onUser
|
||||
<CardTitle className="text-base">Benutzer dieser Filiale</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={() => setShowUserForm(true)}>
|
||||
Benutzer hinzufügen
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="flex-1" onClick={() => setShowAssignDialog(true)}>
|
||||
Benutzer zuordnen
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="flex-1" onClick={() => setShowUserForm(true)}>
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">Keine Benutzer</p>
|
||||
@@ -120,6 +127,19 @@ export default function FilialDetail({ location: loc, onClose, showToast, onUser
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAssignDialog && (
|
||||
<UserAssignDialog
|
||||
location={loc}
|
||||
onClose={() => setShowAssignDialog(false)}
|
||||
onSuccess={() => {
|
||||
loadUsers();
|
||||
setShowAssignDialog(false);
|
||||
onUserAdded?.();
|
||||
}}
|
||||
showToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showUserForm && (
|
||||
<UserCreateForm
|
||||
locationId={loc?.$id}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -8,6 +10,13 @@ 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();
|
||||
@@ -42,7 +51,30 @@ function countInRange(assets, start, 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 || '';
|
||||
@@ -55,15 +87,21 @@ export default function FilialleiterDashboard() {
|
||||
const loadData = useCallback(async () => {
|
||||
if (!locationId) return;
|
||||
try {
|
||||
const [assetsRes, metaRes, locsRes] = await Promise.all([
|
||||
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)]),
|
||||
]);
|
||||
setOwnAssets(assetsRes.documents);
|
||||
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));
|
||||
@@ -86,6 +124,57 @@ export default function FilialleiterDashboard() {
|
||||
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;
|
||||
|
||||
@@ -96,6 +185,7 @@ export default function FilialleiterDashboard() {
|
||||
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,
|
||||
@@ -127,22 +217,72 @@ export default function FilialleiterDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-2">
|
||||
<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>
|
||||
<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>
|
||||
<CardContent className="pt-2">
|
||||
<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>
|
||||
<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>
|
||||
@@ -200,7 +340,11 @@ export default function FilialleiterDashboard() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employeeStats.map((e) => (
|
||||
<TableRow key={e.name}>
|
||||
<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>
|
||||
|
||||
256
src/components/UserAssignDialog.jsx
Normal file
256
src/components/UserAssignDialog.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import UserCreateForm from './UserCreateForm';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
admin: 'Admin',
|
||||
firmenleiter: 'Firmenleiter',
|
||||
filialleiter: 'Filialleiter',
|
||||
service: 'Service',
|
||||
lager: 'Lager',
|
||||
};
|
||||
|
||||
function getLocationName(locationId, locations) {
|
||||
if (!locationId) return 'Nicht zugeordnet';
|
||||
const loc = locations.find((l) => l.$id === locationId);
|
||||
return loc?.name || 'Unbekannte Filiale';
|
||||
}
|
||||
|
||||
export default function UserAssignDialog({
|
||||
location: loc,
|
||||
onClose,
|
||||
onSuccess,
|
||||
showToast,
|
||||
}) {
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatingId, setUpdatingId] = useState(null);
|
||||
const [showUserForm, setShowUserForm] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersRes, locsRes] = await Promise.all([
|
||||
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(500)]),
|
||||
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
|
||||
]);
|
||||
setAllUsers(usersRes.documents);
|
||||
setLocations(locsRes.documents);
|
||||
} catch (err) {
|
||||
console.error('Daten laden fehlgeschlagen:', err);
|
||||
showToast?.('Fehler beim Laden', '#C62828');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const unassigned = allUsers.filter((u) => !u.locationId || u.locationId === '');
|
||||
const assignedToThis = allUsers.filter((u) => u.locationId === loc?.$id);
|
||||
|
||||
async function handleAssign(doc) {
|
||||
if (!loc?.$id) return;
|
||||
setUpdatingId(doc.$id);
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, {
|
||||
locationId: loc.$id,
|
||||
});
|
||||
showToast?.(`${doc.userName || doc.userId} zugeordnet`);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showToast?.('Fehler: ' + (err.message || err), '#C62828');
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnassign(doc) {
|
||||
setUpdatingId(doc.$id);
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, {
|
||||
locationId: '',
|
||||
});
|
||||
showToast?.(`${doc.userName || doc.userId} entfernt`);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showToast?.('Fehler: ' + (err.message || err), '#C62828');
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReassign(doc, newLocationId) {
|
||||
setUpdatingId(doc.$id);
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, {
|
||||
locationId: newLocationId || '',
|
||||
});
|
||||
showToast?.(`${doc.userName || doc.userId} neu zugeordnet`);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showToast?.('Fehler: ' + (err.message || err), '#C62828');
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (showUserForm) {
|
||||
return (
|
||||
<UserCreateForm
|
||||
locationId={loc?.$id}
|
||||
locationName={loc?.name}
|
||||
onSuccess={() => {
|
||||
setShowUserForm(false);
|
||||
loadData();
|
||||
onSuccess?.();
|
||||
}}
|
||||
onCancel={() => setShowUserForm(false)}
|
||||
showToast={showToast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Benutzer zuordnen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bestehende Benutzer der Filiale {loc?.name} zuordnen oder neu anlegen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Laden...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => setShowUserForm(true)}>
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Oben: Nicht zugeordnete Benutzer */}
|
||||
{unassigned.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Noch nicht zugeordnet
|
||||
</h4>
|
||||
<ScrollArea className="h-[180px] rounded-lg border">
|
||||
<div className="space-y-1 p-2">
|
||||
{unassigned.map((u) => (
|
||||
<div
|
||||
key={u.$id}
|
||||
className="flex items-center justify-between rounded border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className="font-medium">{u.userName || u.userId}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{getLocationName(u.locationId, locations)}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{ROLE_LABELS[u.role] || u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={updatingId === u.$id}
|
||||
onClick={() => handleAssign(u)}
|
||||
>
|
||||
{updatingId === u.$id ? '...' : 'Zuordnen'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unten: Bereits zugeordnete Benutzer */}
|
||||
{assignedToThis.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Bereits dieser Filiale zugeordnet
|
||||
</h4>
|
||||
<ScrollArea className="h-[180px] rounded-lg border">
|
||||
<div className="space-y-1 p-2">
|
||||
{assignedToThis.map((u) => (
|
||||
<div
|
||||
key={u.$id}
|
||||
className="flex items-center justify-between gap-2 rounded border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className="font-medium">{u.userName || u.userId}</span>
|
||||
<Badge variant="secondary">
|
||||
{ROLE_LABELS[u.role] || u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(val) => handleReassign(u, val || '')}
|
||||
disabled={updatingId === u.$id}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Umordnen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Nicht zugeordnet</SelectItem>
|
||||
{locations
|
||||
.filter((l) => l.$id !== loc?.$id)
|
||||
.map((l) => (
|
||||
<SelectItem key={l.$id} value={l.$id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={updatingId === u.$id}
|
||||
onClick={() => handleUnassign(u)}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unassigned.length === 0 && assignedToThis.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Keine Benutzer gefunden. Klicken Sie auf "Neuer Benutzer" um einen anzulegen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,16 +26,19 @@ const ROLE_OPTIONS = [
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function UserCreateForm({ locationId, locationName, onSuccess, onCancel, showToast }) {
|
||||
export default function UserCreateForm({ locationId: initialLocationId, locationName, locations = [], onSuccess, onCancel, showToast }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState('service');
|
||||
const [locationId, setLocationId] = useState(initialLocationId || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const effectiveLocationId = initialLocationId || locationId;
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password || !name.trim() || !locationId) return;
|
||||
if (!email.trim() || !password || !name.trim() || !effectiveLocationId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/create-user`, {
|
||||
@@ -45,7 +48,7 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on
|
||||
email: email.trim(),
|
||||
password,
|
||||
name: name.trim(),
|
||||
locationId,
|
||||
locationId: effectiveLocationId,
|
||||
role,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
@@ -71,7 +74,7 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on
|
||||
<DialogHeader>
|
||||
<DialogTitle>Benutzer hinzufügen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Neuer Benutzer für Filiale {locationName || locationId}
|
||||
{initialLocationId ? `Neuer Benutzer für Filiale ${locationName || initialLocationId}` : 'Neuen Benutzer anlegen und Standort zuweisen'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" autoComplete="off">
|
||||
@@ -115,6 +118,23 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{!initialLocationId && locations.length > 0 && (
|
||||
<div>
|
||||
<Label>Standort</Label>
|
||||
<Select value={locationId} onValueChange={setLocationId} required>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Standort wählen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((loc) => (
|
||||
<SelectItem key={loc.$id} value={loc.$id}>
|
||||
{loc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Rolle</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
|
||||
572
src/components/UserDetail.jsx
Normal file
572
src/components/UserDetail.jsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
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 { useAuditLog } from '@/hooks/useAuditLog';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ArrowLeft, Pencil } from 'lucide-react';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
admin: 'Admin',
|
||||
firmenleiter: 'Firmenleiter',
|
||||
filialleiter: 'Filialleiter',
|
||||
service: 'Service',
|
||||
lager: 'Lager',
|
||||
};
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ value: '7', label: 'Letzte 7 Tage' },
|
||||
{ value: '30', label: 'Letzte 30 Tage' },
|
||||
{ value: '90', label: 'Letzte 3 Monate' },
|
||||
{ value: 'all', label: 'Alle' },
|
||||
];
|
||||
|
||||
const STATUS_LABELS = {
|
||||
offen: 'Offen',
|
||||
in_bearbeitung: 'In Bearbeitung',
|
||||
erledigt: 'Erledigt',
|
||||
entsorgt: 'Entsorgt',
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getMonthKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function UserDetail() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { role, user: currentUser, userMeta: myMeta } = useAuth();
|
||||
const isFilialleiterView = location.pathname.startsWith('/filialleiter/mitarbeiter/');
|
||||
const canAccess = role === 'admin' || role === 'firmenleiter' || (role === 'filialleiter' && isFilialleiterView);
|
||||
const { showToast } = useToast();
|
||||
const { logs, loadingLogs, loadLogsByUser, addLog } = useAuditLog();
|
||||
|
||||
const [userMeta, setUserMeta] = useState(null);
|
||||
const [assets, setAssets] = useState([]);
|
||||
const [locations, setLocations] = useState({});
|
||||
const [locationsList, setLocationsList] = useState([]);
|
||||
const [timeRange, setTimeRange] = useState('30');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [editNameValue, setEditNameValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
if (role && !canAccess) {
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<p className="text-destructive">Keine Berechtigung für diese Seite.</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate(isFilialleiterView ? '/filialleiter' : '/admin')}>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isReadOnly = role === 'filialleiter';
|
||||
|
||||
const loadUserMeta = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await databases.listDocuments(DATABASE_ID, 'users_meta', [
|
||||
Query.equal('userId', [userId]),
|
||||
Query.limit(1),
|
||||
]);
|
||||
setUserMeta(res.documents[0] || null);
|
||||
return res.documents[0];
|
||||
} catch (err) {
|
||||
console.error('User-Meta laden fehlgeschlagen:', err);
|
||||
return null;
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const meta = await loadUserMeta();
|
||||
if (!meta?.userName) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const [assetsRes, locsRes] = await Promise.all([
|
||||
databases.listDocuments(DATABASE_ID, 'assets', [
|
||||
Query.equal('zustaendig', [meta.userName]),
|
||||
Query.limit(500),
|
||||
]),
|
||||
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
|
||||
]);
|
||||
setAssets(assetsRes.documents);
|
||||
const locMap = {};
|
||||
locsRes.documents.forEach((l) => { locMap[l.$id] = l.name; });
|
||||
setLocations(locMap);
|
||||
setLocationsList(locsRes.documents);
|
||||
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
if (timeRange !== 'all') {
|
||||
const days = parseInt(timeRange, 10);
|
||||
endDate = new Date();
|
||||
startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate = startDate.toISOString();
|
||||
endDate = endDate.toISOString();
|
||||
}
|
||||
await loadLogsByUser(userId, startDate, endDate);
|
||||
} catch (err) {
|
||||
console.error('UserDetail-Daten laden fehlgeschlagen:', err);
|
||||
showToast?.('Fehler beim Laden', '#C62828');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, timeRange, loadUserMeta, loadLogsByUser, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const editorName = currentUser?.name || currentUser?.email || 'Admin';
|
||||
|
||||
async function handleUpdateName(newName) {
|
||||
const trimmed = (newName || '').trim();
|
||||
if (!trimmed || trimmed === userMeta?.userName) {
|
||||
setEditingName(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
|
||||
},
|
||||
body: JSON.stringify({ userId, userName: trimmed }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
|
||||
return;
|
||||
}
|
||||
setUserMeta((m) => (m ? { ...m, userName: trimmed } : m));
|
||||
setEditingName(false);
|
||||
await addLog({
|
||||
assetId: 'profile',
|
||||
action: 'name_geaendert',
|
||||
details: `Name von "${userMeta?.userName}" zu "${trimmed}" geändert (durch ${editorName})`,
|
||||
userId,
|
||||
userName: userMeta?.userName,
|
||||
});
|
||||
loadData();
|
||||
showToast?.('Name aktualisiert');
|
||||
} catch (err) {
|
||||
showToast?.(err.message || 'Fehler', '#C62828');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateRole(newRole) {
|
||||
if (!newRole || newRole === userMeta?.role) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
|
||||
},
|
||||
body: JSON.stringify({ userId, role: newRole }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
|
||||
return;
|
||||
}
|
||||
const oldRole = userMeta?.role;
|
||||
setUserMeta((m) => (m ? { ...m, role: newRole } : m));
|
||||
await addLog({
|
||||
assetId: 'profile',
|
||||
action: 'rolle_geaendert',
|
||||
details: `Rolle von "${ROLE_LABELS[oldRole] || oldRole}" zu "${ROLE_LABELS[newRole] || newRole}" geändert (durch ${editorName})`,
|
||||
userId,
|
||||
userName: userMeta?.userName,
|
||||
});
|
||||
loadLogsByUser(userId);
|
||||
showToast?.('Rolle aktualisiert');
|
||||
} catch (err) {
|
||||
showToast?.(err.message || 'Fehler', '#C62828');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(newLocationId) {
|
||||
if (newLocationId === userMeta?.locationId) return;
|
||||
setSaving(true);
|
||||
const oldName = locations[userMeta?.locationId || ''] || 'Nicht zugeordnet';
|
||||
const newName = newLocationId ? (locations[newLocationId] || newLocationId) : 'Nicht zugeordnet';
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
|
||||
},
|
||||
body: JSON.stringify({ userId, locationId: newLocationId || '' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
|
||||
return;
|
||||
}
|
||||
setUserMeta((m) => (m ? { ...m, locationId: newLocationId || '' } : m));
|
||||
await addLog({
|
||||
assetId: 'profile',
|
||||
action: 'filiale_geaendert',
|
||||
details: `Filiale von "${oldName}" zu "${newName}" geändert (durch ${editorName})`,
|
||||
userId,
|
||||
userName: userMeta?.userName,
|
||||
});
|
||||
loadLogsByUser(userId);
|
||||
loadData();
|
||||
showToast?.('Filiale aktualisiert');
|
||||
} catch (err) {
|
||||
showToast?.(err.message || 'Fehler', '#C62828');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
const groups = { offen: [], in_bearbeitung: [], erledigt: [], entsorgt: [] };
|
||||
assets.forEach((a) => {
|
||||
const k = a.status || 'offen';
|
||||
if (groups[k]) groups[k].push(a);
|
||||
else groups.erledigt.push(a);
|
||||
});
|
||||
return groups;
|
||||
}, [assets]);
|
||||
|
||||
const monthlyStats = useMemo(() => {
|
||||
const map = {};
|
||||
logs.forEach((log) => {
|
||||
const d = new Date(log.$createdAt);
|
||||
const key = getMonthKey(d);
|
||||
if (!map[key]) map[key] = { actions: 0 };
|
||||
map[key].actions += 1;
|
||||
});
|
||||
assets.forEach((a) => {
|
||||
if (a.status === 'erledigt' || a.status === 'entsorgt') {
|
||||
const d = new Date(a.$updatedAt || a.$createdAt);
|
||||
const key = getMonthKey(d);
|
||||
if (!map[key]) map[key] = { actions: 0, completed: 0 };
|
||||
map[key].completed = (map[key].completed || 0) + 1;
|
||||
}
|
||||
});
|
||||
return Object.entries(map)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.slice(0, 6)
|
||||
.map(([month, data]) => ({ month, ...data }));
|
||||
}, [logs, assets]);
|
||||
|
||||
if (loading && !userMeta) {
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin')} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<p className="py-8 text-center text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const backPath = isFilialleiterView ? '/filialleiter' : '/admin';
|
||||
|
||||
if (!userMeta) {
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<p className="text-muted-foreground">Benutzer nicht gefunden.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isReadOnly && userMeta.locationId !== myMeta?.locationId) {
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<p className="text-destructive">Dieser Mitarbeiter gehört nicht zu deiner Filiale.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingName && !isReadOnly ? (
|
||||
<>
|
||||
<Input
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleUpdateName(editNameValue);
|
||||
if (e.key === 'Escape') { setEditingName(false); setEditNameValue(''); }
|
||||
}}
|
||||
onBlur={() => handleUpdateName(editNameValue)}
|
||||
disabled={saving}
|
||||
className="h-9 max-w-[300px] text-lg font-bold"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingName(false); setEditNameValue(''); }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{userMeta?.userName || userId}
|
||||
</h1>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => { setEditNameValue(userMeta?.userName || ''); setEditingName(true); }}
|
||||
disabled={saving}
|
||||
title="Name bearbeiten"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={userMeta?.role || ''}
|
||||
onValueChange={handleUpdateRole}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ROLE_LABELS).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={userMeta?.locationId || '_none'}
|
||||
onValueChange={(v) => handleUpdateLocation(v === '_none' ? '' : v)}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[160px]">
|
||||
<SelectValue placeholder="Standort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">Nicht zugeordnet</SelectItem>
|
||||
{locationsList.map((loc) => (
|
||||
<SelectItem key={loc.$id} value={loc.$id}>{loc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Audit-Logs */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Benutzer-Logs</CardTitle>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingLogs ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Laden...</p>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Keine Logs im gewählten Zeitraum
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[280px]">
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.$id}
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(log.$createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{log.details && (
|
||||
<p className="mt-1 text-muted-foreground">{log.details}</p>
|
||||
)}
|
||||
{log.assetId && log.assetId !== 'profile' && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => navigate(`/asset/${log.assetId}`)}
|
||||
>
|
||||
Asset öffnen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aufgaben */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aufgaben (zugeordnete Assets)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{['offen', 'in_bearbeitung', 'entsorgt', 'erledigt'].map((status) => (
|
||||
<div key={status}>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
{STATUS_LABELS[status]} ({byStatus[status]?.length || 0})
|
||||
</h4>
|
||||
<ScrollArea className="h-[120px] rounded border">
|
||||
<div className="space-y-1 p-2">
|
||||
{(byStatus[status] || []).map((a) => (
|
||||
<div
|
||||
key={a.$id}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
<span className="truncate">{a.erlNummer || a.bezeichnung || a.$id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => navigate(`/asset/${a.$id}`)}
|
||||
>
|
||||
Öffnen
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stats / Monatsvergleich */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Monats-Statistik</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{monthlyStats.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Noch keine auswertbaren Daten
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{monthlyStats.map(({ month, actions = 0, completed = 0 }) => (
|
||||
<div
|
||||
key={month}
|
||||
className="flex flex-col gap-2 rounded-lg border p-4 min-w-[140px]"
|
||||
>
|
||||
<span className="font-medium">{month}</span>
|
||||
<div className="text-2xl font-bold">{actions} Aktionen</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{completed} abgeschlossen
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{
|
||||
width: `${Math.min(100, (completed / Math.max(actions, 1)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
312
src/components/ui/chart.jsx
Normal file
312
src/components/ui/chart.jsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = {
|
||||
light: "",
|
||||
dark: ".dark"
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({
|
||||
id,
|
||||
config
|
||||
}) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`)
|
||||
.join("\n"),
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor
|
||||
}
|
||||
} />
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}} />
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config,
|
||||
payload,
|
||||
key
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key]
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key]
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
Reference in New Issue
Block a user