fieles neues

This commit is contained in:
KNSONWS
2026-03-19 21:13:55 +01:00
parent 9a39120919
commit ad02198671
19 changed files with 2234 additions and 125 deletions

View File

@@ -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>
</>
);

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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 &quot;Neuer Benutzer&quot; um einen anzulegen.
</p>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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}>

View 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
View 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,
}