31 von 45 = ca. 69 %

31 punkter der todo liste abgeabeitet
This commit is contained in:
2026-03-08 09:20:39 +01:00
parent 43c9efd8f5
commit 9b9b8d39a8
37 changed files with 2757 additions and 1882 deletions

80
package-lock.json generated
View File

@@ -16,9 +16,12 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.0", "shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.14", "tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
@@ -6163,6 +6166,16 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-appwrite": { "node_modules/node-appwrite": {
"version": "22.1.3", "version": "22.1.3",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz", "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz",
@@ -6915,6 +6928,57 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/recast": { "node_modules/recast": {
"version": "0.23.11", "version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -7278,6 +7342,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7490,6 +7560,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -19,9 +19,12 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.0", "shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.14", "tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,20 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite'; import { databases, DATABASE_ID } from '@/lib/appwrite';
import { ID, Query } from 'appwrite'; import { ID, Query } from 'appwrite';
import Header from './Header'; import Header from './Header';
import Toast from './Toast'; import { useToast } from '@/hooks/useToast';
import { useToast } from '../hooks/useToast'; import { useAuth } from '@/context/AuthContext';
import { useAuth } from '../context/AuthContext';
import LagerstandortManager from './LagerstandortManager'; import LagerstandortManager from './LagerstandortManager';
import { useLagerstandorte } from '../hooks/useLagerstandorte'; import { useLagerstandorte } from '@/hooks/useLagerstandorte';
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';
export default function AdminPanel() { export default function AdminPanel() {
const { user, userMeta } = useAuth(); const { user, userMeta } = useAuth();
const { toast, showToast } = useToast(); const { showToast } = useToast();
const locationId = userMeta?.locationId || ''; const locationId = userMeta?.locationId || '';
const { lagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId); const { lagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
@@ -115,141 +119,157 @@ export default function AdminPanel() {
} }
} }
const statItems = [
{ label: 'Benutzer', value: stats.users },
{ label: 'Filialen', value: stats.locations },
{ label: 'Assets gesamt', value: stats.assets },
{ label: 'Lagerstandorte', value: stats.lagerstandorte },
];
return ( return (
<> <>
<Header showToast={showToast} /> <Header showToast={showToast} />
<div className="panel-page"> <div className="mx-auto max-w-7xl p-6">
<div className="panel-title-bar"> <div className="mb-8">
<h1>Admin Panel</h1> <h1 className="text-3xl font-bold tracking-tight">Admin Panel</h1>
<p>System-Übersicht und Verwaltung</p> <p className="mt-1 text-muted-foreground">System-Übersicht und Verwaltung</p>
</div> </div>
<div className="panel-stats"> <div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="panel-stat-card"> {statItems.map((item) => (
<div className="panel-stat-number">{stats.users}</div> <Card key={item.label}>
<div className="panel-stat-label">Benutzer</div> <CardContent className="pt-2">
</div> <div className="text-3xl font-bold">{item.value}</div>
<div className="panel-stat-card"> <p className="text-sm text-muted-foreground">{item.label}</p>
<div className="panel-stat-number">{stats.locations}</div> </CardContent>
<div className="panel-stat-label">Filialen</div> </Card>
</div> ))}
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.assets}</div>
<div className="panel-stat-label">Assets gesamt</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.lagerstandorte}</div>
<div className="panel-stat-label">Lagerstandorte</div>
</div>
</div> </div>
<div className="panel-grid"> <div className="grid gap-6 lg:grid-cols-2">
<div className="panel-card"> {/* Filialen */}
<h2>Filialen verwalten</h2> <Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Filialen verwalten</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleAddFiliale}>
<Input
value={newFiliale.name}
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname (z.B. Kaiserslautern)"
/>
<Input
value={newFiliale.address}
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse (optional)"
/>
<Button type="submit" disabled={addingFiliale || !newFiliale.name.trim()}>
{addingFiliale ? '...' : 'Hinzufügen'}
</Button>
</form>
<form className="filiale-add-form" onSubmit={handleAddFiliale}> <Separator className="my-4" />
<input
type="text"
className="filiale-input"
value={newFiliale.name}
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname (z.B. Kaiserslautern)"
/>
<input
type="text"
className="filiale-input"
value={newFiliale.address}
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse (optional)"
/>
<button type="submit" className="btn-panel-action" disabled={addingFiliale || !newFiliale.name.trim()}>
{addingFiliale ? '...' : 'Filiale hinzufügen'}
</button>
</form>
<div className="panel-list" style={{ marginTop: 16 }}> <div className="space-y-3">
{locations.length === 0 && <p className="panel-empty">Keine Filialen vorhanden</p>} {locations.length === 0 && (
{locations.map((loc) => ( <p className="py-4 text-center text-sm text-muted-foreground">Keine Filialen vorhanden</p>
<div key={loc.$id} className={`filiale-admin-item ${loc.isActive ? '' : 'inactive'}`}> )}
{editingId === loc.$id ? ( {locations.map((loc) => (
<div className="filiale-edit-row"> <div
<input key={loc.$id}
type="text" className={`flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between ${!loc.isActive ? 'opacity-60' : ''}`}
className="filiale-input" >
value={editForm.name} {editingId === loc.$id ? (
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))} <div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
placeholder="Filialname" <Input
/> value={editForm.name}
<input onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
type="text" placeholder="Filialname"
className="filiale-input" className="flex-1"
value={editForm.address} />
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))} <Input
placeholder="Adresse" value={editForm.address}
/> onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
<div className="filiale-edit-btns"> placeholder="Adresse"
<button className="btn-action btn-status" onClick={handleSaveEdit}>Speichern</button> className="flex-1"
<button className="btn-action btn-info" onClick={() => setEditingId(null)}>Abbrechen</button> />
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveEdit}>Speichern</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Abbrechen</Button>
</div>
</div> </div>
</div> ) : (
) : ( <>
<> <div className="flex items-center gap-3">
<div className="filiale-admin-info"> <span className="font-medium">{loc.name}</span>
<strong>{loc.name}</strong> {loc.address && <span className="text-sm text-muted-foreground">{loc.address}</span>}
{loc.address && <span className="panel-list-sub">{loc.address}</span>} <Badge variant={loc.isActive ? 'default' : 'outline'}>
<span className={`panel-badge ${loc.isActive ? 'active' : 'inactive'}`}> {loc.isActive ? 'Aktiv' : 'Inaktiv'}
{loc.isActive ? 'Aktiv' : 'Inaktiv'} </Badge>
</span> </div>
</div> <div className="flex gap-2">
<div className="filiale-admin-actions"> <Button size="sm" variant="outline" onClick={() => startEdit(loc)}>Bearbeiten</Button>
<button className="btn-action btn-info" onClick={() => startEdit(loc)}>Bearbeiten</button> <Button size="sm" variant="secondary" onClick={() => handleToggleFiliale(loc.$id)}>
<button className="btn-action btn-status" onClick={() => handleToggleFiliale(loc.$id)}> {loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'} </Button>
</button> <Button size="sm" variant="destructive" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</Button>
<button className="btn-action btn-delete" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</button> </div>
</div> </>
</> )}
)}
</div>
))}
</div>
</div>
<div className="panel-card">
<h2>Benutzer</h2>
<div className="panel-list">
{usersList.length === 0 && <p className="panel-empty">Keine Benutzer vorhanden</p>}
{usersList.map((u) => {
const loc = locations.find((l) => l.$id === u.locationId);
return (
<div key={u.$id} className="panel-list-item">
<div>
<strong>{u.userName || u.userId}</strong>
<span className="panel-list-sub">{u.role}</span>
</div>
<span className="panel-list-sub">{loc?.name || ''}</span>
</div> </div>
); ))}
})} </div>
</div> </CardContent>
</div> </Card>
<div className="panel-card"> {/* Benutzer */}
<h2>Lagerstandorte</h2> <Card>
<button className="btn-panel-action" onClick={() => setShowLsManager(true)}> <CardHeader>
Lagerstandorte verwalten <CardTitle>Benutzer</CardTitle>
</button> </CardHeader>
<div className="panel-list" style={{ marginTop: 12 }}> <CardContent>
{lagerstandorte.map((l) => ( <div className="space-y-3">
<div key={l.$id} className="panel-list-item"> {usersList.length === 0 && (
<span>{l.name}</span> <p className="py-4 text-center text-sm text-muted-foreground">Keine Benutzer vorhanden</p>
<span className={`panel-badge ${l.isActive ? 'active' : 'inactive'}`}> )}
{l.isActive ? 'Aktiv' : 'Inaktiv'} {usersList.map((u) => {
</span> const loc = locations.find((l) => l.$id === u.locationId);
</div> return (
))} <div key={u.$id} className="flex items-center justify-between rounded-lg border p-3">
</div> <div className="flex items-center gap-2">
</div> <span className="font-medium">{u.userName || u.userId}</span>
<Badge variant="secondary">{u.role}</Badge>
</div>
<span className="text-sm text-muted-foreground">{loc?.name || ''}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Lagerstandorte */}
<Card>
<CardHeader>
<CardTitle>Lagerstandorte</CardTitle>
</CardHeader>
<CardContent>
<Button className="mb-4 w-full" onClick={() => setShowLsManager(true)}>
Lagerstandorte verwalten
</Button>
<div className="space-y-2">
{lagerstandorte.map((l) => (
<div key={l.$id} className="flex items-center justify-between rounded-lg border p-3">
<span className="text-sm">{l.name}</span>
<Badge variant={l.isActive ? 'default' : 'outline'}>
{l.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
@@ -262,7 +282,6 @@ export default function AdminPanel() {
onClose={() => setShowLsManager(false)} onClose={() => setShowLsManager(false)}
/> />
)} )}
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</> </>
); );
} }

View File

@@ -1,14 +1,28 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { databases, DATABASE_ID } from '../lib/appwrite'; import { databases, DATABASE_ID } from '@/lib/appwrite';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useAuditLog } from '../hooks/useAuditLog'; import { useAuditLog } from '@/hooks/useAuditLog';
import { useLagerstandorte } from '../hooks/useLagerstandorte'; import { useLagerstandorte } from '@/hooks/useLagerstandorte';
import { useColleagues } from '../hooks/useColleagues'; import { useColleagues } from '@/hooks/useColleagues';
import { getDaysOld, isOverdue } from '../hooks/useAssets'; import { getDaysOld, isOverdue } from '@/hooks/useAssets';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Pencil, Save, X } from 'lucide-react';
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' }; const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
const STATUS_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' }; const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig']; const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
const STATUS_OPTIONS = ['offen', 'in_bearbeitung', 'entsorgt']; const STATUS_OPTIONS = ['offen', 'in_bearbeitung', 'entsorgt'];
@@ -20,6 +34,19 @@ function formatTimestamp(ts) {
+ ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} }
function StatusBadge({ status }) {
if (status === 'offen') return <Badge variant="destructive">{STATUS_LABEL[status]}</Badge>;
if (status === 'in_bearbeitung') return <Badge className="border-amber-300 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">{STATUS_LABEL[status]}</Badge>;
return <Badge variant="secondary">{STATUS_LABEL[status]}</Badge>;
}
function PrioBadge({ prio }) {
if (prio === 'kritisch') return <Badge variant="destructive">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'hoch') return <Badge className="border-orange-300 bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">{PRIO_LABELS[prio]}</Badge>;
if (prio === 'mittel') return <Badge className="border-yellow-300 bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">{PRIO_LABELS[prio]}</Badge>;
return <Badge className="border-green-300 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">{PRIO_LABELS[prio]}</Badge>;
}
export default function AssetDetail() { export default function AssetDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -141,22 +168,47 @@ export default function AssetDetail() {
} }
} }
function resetForm() {
setEditing(false);
setForm({
erlNummer: asset.erlNummer || '',
seriennummer: asset.seriennummer || '',
artikelNr: asset.artikelNr || '',
bezeichnung: asset.bezeichnung || '',
defekt: asset.defekt || '',
lagerstandortId: asset.lagerstandortId || '',
zustaendig: asset.zustaendig || '',
status: asset.status || 'offen',
prio: asset.prio || 'mittel',
kommentar: asset.kommentar || '',
});
}
if (loading) { if (loading) {
return ( return (
<div className="asset-detail-page"> <div className="mx-auto flex max-w-4xl flex-col items-center gap-4 p-6 pt-24">
<div className="asset-detail-loading">Lade Asset</div> <Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div> </div>
); );
} }
if (!asset) { if (!asset) {
return ( return (
<div className="asset-detail-page"> <div className="mx-auto max-w-4xl p-6 pt-12">
<div className="asset-detail-not-found"> <Card>
<h2>Asset nicht gefunden</h2> <CardContent className="flex flex-col items-center gap-4 py-12">
<p>Das Asset mit der ID <code>{id}</code> existiert nicht.</p> <h2 className="text-lg font-semibold">Asset nicht gefunden</h2>
<button className="btn-back" onClick={() => navigate('/tracker')}>Zurück zur Übersicht</button> <p className="text-sm text-muted-foreground">
</div> Das Asset mit der ID <code className="rounded bg-muted px-1 py-0.5 text-xs">{id}</code> existiert nicht.
</p>
<Button variant="outline" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Zurück zur Übersicht
</Button>
</CardContent>
</Card>
</div> </div>
); );
} }
@@ -165,154 +217,193 @@ export default function AssetDetail() {
const overdue = isOverdue(asset); const overdue = isOverdue(asset);
return ( return (
<div className="asset-detail-page"> <div className="mx-auto max-w-4xl p-6">
<div className="asset-detail-header"> {/* Back button */}
<button className="btn-back" onClick={() => navigate('/tracker')}> Zurück</button> <Button variant="outline" className="mb-4" onClick={() => navigate('/tracker')}>
<h1> <ArrowLeft className="mr-1.5 h-4 w-4" />
Asset: <span style={{ color: '#1565C0' }}>{asset.erlNummer || ''}</span> Zurück
</Button>
{/* Header area */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">
Asset: <span className="text-blue-600 dark:text-blue-400">{asset.erlNummer || ''}</span>
</h1> </h1>
<div className="asset-detail-meta"> <StatusBadge status={asset.status} />
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span> <PrioBadge prio={asset.prio} />
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span> {overdue && (
{overdue && <span className="age-warn">Überfällig ({days} Tage)</span>} <Badge variant="destructive" className="text-xs">
</div> Überfällig ({days} Tage)
</Badge>
)}
</div> </div>
<div className="asset-detail-card"> {/* Properties card */}
<div className="asset-detail-card-header"> <Card className="mb-6">
<h2>Eigenschaften</h2> <CardHeader className="flex-row items-center justify-between">
{!editing ? ( <CardTitle>Eigenschaften</CardTitle>
<button className="btn-edit" onClick={() => setEditing(true)}>Bearbeiten</button> <div className="flex gap-2">
) : ( {!editing ? (
<div className="edit-actions"> <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<button className="btn-save" onClick={handleSave} disabled={saving}> <Pencil className="mr-1.5 h-3.5 w-3.5" />
{saving ? 'Speichern…' : 'Speichern'} Bearbeiten
</button> </Button>
<button className="btn-cancel" onClick={() => { setEditing(false); setForm({ ) : (
erlNummer: asset.erlNummer || '', <>
seriennummer: asset.seriennummer || '', <Button size="sm" onClick={handleSave} disabled={saving}>
artikelNr: asset.artikelNr || '', <Save className="mr-1.5 h-3.5 w-3.5" />
bezeichnung: asset.bezeichnung || '', {saving ? 'Speichern…' : 'Speichern'}
defekt: asset.defekt || '', </Button>
lagerstandortId: asset.lagerstandortId || '', <Button variant="outline" size="sm" onClick={resetForm}>
zustaendig: asset.zustaendig || '', <X className="mr-1.5 h-3.5 w-3.5" />
status: asset.status || 'offen', Abbrechen
prio: asset.prio || 'mittel', </Button>
kommentar: asset.kommentar || '', </>
}); }}>Abbrechen</button> )}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<PropertyField label="ERL-Nr." value={form.erlNummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
<PropertyField label="Artikelnr." value={form.artikelNr} editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
<PropertyField label="Bezeichnung" value={form.bezeichnung} editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
<PropertyField label="Seriennummer" value={form.seriennummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
<PropertyField label="Defekt" value={form.defekt} editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea className="sm:col-span-2" />
{/* Lagerstandort */}
<div className="space-y-1.5">
<Label>Lagerstandort</Label>
{editing ? (
<Select value={form.lagerstandortId} onValueChange={(v) => setForm(f => ({ ...f, lagerstandortId: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Kein Standort" />
</SelectTrigger>
<SelectContent>
{activeLagerstandorte.map((l) => (
<SelectItem key={l.$id} value={l.$id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</p>
)}
</div> </div>
)}
</div>
<div className="asset-props-grid"> {/* Zuständig */}
<PropertyRow label="ERL-Nr." value={form.erlNummer} field="erlNummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} /> <div className="space-y-1.5">
<PropertyRow label="Artikelnr." value={form.artikelNr} field="artikelNr" editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} /> <Label>Zuständig</Label>
<PropertyRow label="Bezeichnung" value={form.bezeichnung} field="bezeichnung" editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} /> {editing ? (
<PropertyRow label="Seriennummer" value={form.seriennummer} field="seriennummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono /> <Select value={form.zustaendig} onValueChange={(v) => setForm(f => ({ ...f, zustaendig: v }))}>
<PropertyRow label="Defekt" value={form.defekt} field="defekt" editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea /> <SelectTrigger className="w-full">
<div className="prop-row"> <SelectValue placeholder="Mitarbeiter wählen" />
<span className="prop-label">Lagerstandort</span> </SelectTrigger>
{editing ? ( <SelectContent>
<select className="prop-input" value={form.lagerstandortId} onChange={(e) => setForm(f => ({ ...f, lagerstandortId: e.target.value }))}> {colleagues.map((c) => (
<option value=""> Kein Standort </option> <SelectItem key={c.userId} value={c.userName}>
{activeLagerstandorte.map((l) => ( {c.userName}{c.userName === userName ? ' (Ich)' : ''}
<option key={l.$id} value={l.$id}>{l.name}</option> </SelectItem>
))} ))}
</select> </SelectContent>
) : ( </Select>
<span className="prop-value">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</span> ) : (
)} <p className="text-sm">{asset.zustaendig || ''}</p>
</div> )}
<div className="prop-row"> </div>
<span className="prop-label">Zuständig</span>
{editing ? (
<select className="prop-input" value={form.zustaendig} onChange={(e) => setForm(f => ({ ...f, zustaendig: e.target.value }))}>
<option value=""> Mitarbeiter wählen </option>
{colleagues.map((c) => (
<option key={c.userId} value={c.userName}>
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
</option>
))}
</select>
) : (
<span className="prop-value">{asset.zustaendig || ''}</span>
)}
</div>
<div className="prop-row">
<span className="prop-label">Status</span>
{editing ? (
<select className="prop-input" value={form.status} onChange={(e) => setForm(f => ({ ...f, status: e.target.value }))}>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
) : (
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
)}
</div>
<div className="prop-row">
<span className="prop-label">Priorität</span>
{editing ? (
<select className="prop-input" value={form.prio} onChange={(e) => setForm(f => ({ ...f, prio: e.target.value }))}>
{PRIO_OPTIONS.map((p) => (
<option key={p} value={p}>{PRIO_LABELS[p]}</option>
))}
</select>
) : (
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
)}
</div>
<PropertyRow label="Kommentar" value={form.kommentar} field="kommentar" editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea />
</div>
<div className="asset-info-footer"> {/* Status */}
<div className="space-y-1.5">
<Label>Status</Label>
{editing ? (
<Select value={form.status} onValueChange={(v) => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABEL[s]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge status={asset.status} />
)}
</div>
{/* Priorität */}
<div className="space-y-1.5">
<Label>Priorität</Label>
{editing ? (
<Select value={form.prio} onValueChange={(v) => setForm(f => ({ ...f, prio: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIO_OPTIONS.map((p) => (
<SelectItem key={p} value={p}>{PRIO_LABELS[p]}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<PrioBadge prio={asset.prio} />
)}
</div>
<PropertyField label="Kommentar" value={form.kommentar} editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
</div>
</CardContent>
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>Erstellt am: {formatTimestamp(asset.$createdAt)}</span> <span>Erstellt am: {formatTimestamp(asset.$createdAt)}</span>
<span>Erstellt von: <strong>{asset.createdBy || ''}</strong></span> <span>Erstellt von: <strong className="text-foreground">{asset.createdBy || ''}</strong></span>
<span>Zuletzt bearbeitet von: <strong>{asset.lastEditedBy || ''}</strong></span> <span>Zuletzt bearbeitet von: <strong className="text-foreground">{asset.lastEditedBy || ''}</strong></span>
<span>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span> <span>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span>
</div> </CardFooter>
</div> </Card>
<div className="asset-log-card"> {/* Audit log card */}
<h2>Änderungsprotokoll</h2> <Card>
<div className="log-console"> <CardHeader>
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen</div>} <CardTitle>Änderungsprotokoll</CardTitle>
{!loadingLogs && logs.length === 0 && ( </CardHeader>
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div> <CardContent>
)} <div className="log-console">
{logs.map((log) => { {loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen</div>}
const ts = formatTimestamp(log.$createdAt); {!loadingLogs && logs.length === 0 && (
const actionClass = log.action === 'erstellt' ? 'log-created' <div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
: log.action === 'status_geaendert' ? 'log-status' )}
: 'log-edit'; {logs.map((log) => {
const ts = formatTimestamp(log.$createdAt);
const actionClass = log.action === 'erstellt' ? 'log-created'
: log.action === 'status_geaendert' ? 'log-status'
: 'log-edit';
return ( return (
<div key={log.$id} className={`log-entry ${actionClass}`}> <div key={log.$id} className={`log-entry ${actionClass}`}>
<span className="log-time">[{ts}]</span> <span className="log-time">[{ts}]</span>
<span className="log-user">{log.userName}</span> <span className="log-user">{log.userName}</span>
<span className="log-action">{log.action.toUpperCase()}</span> <span className="log-action">{log.action.toUpperCase()}</span>
{log.details && <span className="log-details">{log.details}</span>} {log.details && <span className="log-details">{log.details}</span>}
</div> </div>
); );
})} })}
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
); );
} }
function PropertyRow({ label, value, editing, onChange, mono, textarea }) { function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
return ( return (
<div className="prop-row"> <div className={`space-y-1.5 ${className}`}>
<span className="prop-label">{label}</span> <Label>{label}</Label>
{editing ? ( {editing ? (
textarea ? ( textarea ? (
<textarea className="prop-input" value={value} onChange={(e) => onChange(e.target.value)} rows={3} /> <Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
) : ( ) : (
<input className="prop-input" type="text" value={value} onChange={(e) => onChange(e.target.value)} /> <Input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
) )
) : ( ) : (
<span className={`prop-value${mono ? ' mono' : ''}`}>{value || ''}</span> <p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || ''}</p>
)} )}
</div> </div>
); );

View File

@@ -1,43 +1,34 @@
import { useState, useRef, useEffect } from 'react'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { ChevronDown } from 'lucide-react';
export default function ColumnFilter({ label, active, summary, children, onOpen, onClose }) { export default function ColumnFilter({ label, active, summary, children, onOpen, onClose }) {
const ref = useRef(null);
useEffect(() => {
if (!active) return;
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [active, onClose]);
return ( return (
<th className="col-filter-th" ref={ref}> <th className="h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground">
<button className={`col-filter-btn ${active ? 'active' : ''} ${summary ? 'has-filter' : ''}`} onClick={active ? onClose : onOpen}> <Popover open={active} onOpenChange={(nextOpen) => (nextOpen ? onOpen() : onClose())}>
<span className="col-filter-label">{label}</span> <PopoverTrigger className="inline-flex items-center gap-1.5 text-sm font-medium cursor-pointer transition-colors hover:text-foreground/70">
{summary && <span className="col-filter-summary">{summary}</span>} <span>{label}</span>
<span className={`col-filter-arrow ${active ? 'open' : ''}`}>&#9662;</span> {summary && (
</button> <span className="text-xs font-normal text-amber-600 dark:text-amber-400 truncate max-w-20">
{active && ( {summary}
<div className="col-filter-popup"> </span>
)}
<ChevronDown
className={`h-3 w-3 shrink-0 transition-transform duration-200 ${active ? 'rotate-180' : ''}`}
/>
</PopoverTrigger>
<PopoverContent align="start" className="w-56">
{children} {children}
</div> </PopoverContent>
)} </Popover>
</th> </th>
); );
} }
export function TextFilter({ value, onChange, placeholder }) { export function TextFilter({ value, onChange, placeholder }) {
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
return ( return (
<input <Input
ref={inputRef} autoFocus
className="col-filter-input"
type="text" type="text"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
@@ -48,9 +39,13 @@ export function TextFilter({ value, onChange, placeholder }) {
export function SelectFilter({ value, onChange, options }) { export function SelectFilter({ value, onChange, options }) {
return ( return (
<div className="col-filter-options"> <div className="flex flex-col gap-0.5">
<button <button
className={`col-filter-option ${!value ? 'selected' : ''}`} className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
!value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground'
}`}
onClick={() => onChange('')} onClick={() => onChange('')}
> >
Alle Alle
@@ -58,7 +53,11 @@ export function SelectFilter({ value, onChange, options }) {
{options.map((opt) => ( {options.map((opt) => (
<button <button
key={opt.value} key={opt.value}
className={`col-filter-option ${value === opt.value ? 'selected' : ''}`} className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
value === opt.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground'
}`}
onClick={() => onChange(opt.value)} onClick={() => onChange(opt.value)}
> >
{opt.label} {opt.label}

View File

@@ -1,3 +1,13 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export default function CommentPopup({ artikel, onClose }) { export default function CommentPopup({ artikel, onClose }) {
let subject = ''; let subject = '';
let text = artikel.kommentar; let text = artikel.kommentar;
@@ -9,14 +19,32 @@ export default function CommentPopup({ artikel, onClose }) {
} }
return ( return (
<> <Dialog open={true} onOpenChange={onClose}>
<div className="comment-overlay" onClick={onClose} /> <DialogContent>
<div className="comment-popup"> <DialogHeader>
<h3>Kommentar zu {artikel.erlNummer}</h3> <DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
{subject && <div className="subject">{subject}</div>} <DialogDescription className="sr-only">
<div className="text">{text || '(Kein weiterer Kommentar)'}</div> Kommentardetails anzeigen
<button className="close-btn" onClick={onClose}>Schließen</button> </DialogDescription>
</div> </DialogHeader>
</>
<div className="space-y-3">
{subject && (
<div className="rounded-md bg-amber-100 px-3 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
{subject}
</div>
)}
<div className="rounded-md bg-muted px-3 py-2 text-sm whitespace-pre-wrap">
{text || '(Kein weiterer Kommentar)'}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }

View File

@@ -2,6 +2,14 @@ import { useState } from 'react';
import { isOverdue } from '../hooks/useAssets'; import { isOverdue } from '../hooks/useAssets';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import LagerstandortManager from './LagerstandortManager'; import LagerstandortManager from './LagerstandortManager';
import { Card, CardContent } from '@/components/ui/card';
const STAT_CARDS = [
{ key: 'offen', color: '#DC2626', label: 'Offen' },
{ key: 'bearbeitung', color: '#F59E0B', label: 'In Bearbeitung' },
{ key: 'entsorgt', color: '#6B7280', label: 'Entsorgt' },
{ key: 'overdue', color: '#2563EB', label: 'Überfällig (>7 Tage)' },
];
export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, onToggleLagerstandort, onDeleteLagerstandort }) { export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, onToggleLagerstandort, onDeleteLagerstandort }) {
const { isAdmin, isFilialleiter } = useAuth(); const { isAdmin, isFilialleiter } = useAuth();
@@ -16,16 +24,27 @@ export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort,
return ( return (
<> <>
<div className="dashboard"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
<StatCard color="red" count={counts.offen} label="Offen" /> {STAT_CARDS.map(({ key, color, label }) => (
<StatCard color="yellow" count={counts.bearbeitung} label="In Bearbeitung" /> <Card key={key} className="py-0" style={{ borderTop: `3px solid ${color}` }}>
<StatCard color="gray" count={counts.entsorgt} label="Entsorgt" /> <CardContent className="py-5">
<StatCard color="blue" count={counts.overdue} label="Überfällig (>7 Tage)" /> <div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className="text-sm text-muted-foreground mt-1">{label}</p>
</CardContent>
</Card>
))}
{(isAdmin || isFilialleiter) && ( {(isAdmin || isFilialleiter) && (
<div className="stat-card" style={{ borderColor: '#F57C00', cursor: 'pointer' }} onClick={() => setShowManager(true)}> <Card
<div className="stat-number" style={{ fontSize: '24px' }}>{lagerstandorte.length}</div> className="py-0 cursor-pointer transition-colors hover:bg-muted/50"
<div className="stat-label">Lagerstandorte verwalten</div> style={{ borderTop: '3px solid #F57C00' }}
</div> onClick={() => setShowManager(true)}
>
<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>
</CardContent>
</Card>
)} )}
</div> </div>
@@ -41,12 +60,3 @@ export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort,
</> </>
); );
} }
function StatCard({ color, count, label }) {
return (
<div className={`stat-card ${color}`}>
<div className="stat-number">{count}</div>
<div className="stat-label">{label}</div>
</div>
);
}

View File

@@ -1,5 +1,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const EMPTY_FORM = { const EMPTY_FORM = {
erlNummer: '', erlNummer: '',
@@ -29,6 +41,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
setForm((prev) => ({ ...prev, [name]: value })); setForm((prev) => ({ ...prev, [name]: value }));
} }
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@@ -57,64 +73,130 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
} }
return ( return (
<div className="form-card"> <Card className="border-0 shadow-none">
<div className="form-header">Defekte Ware erfassen</div> <CardHeader className="px-0 pt-0">
<form className="form-body" onSubmit={handleSubmit}> <CardTitle>Defekte Ware erfassen</CardTitle>
<div className="form-group"> </CardHeader>
<label>ERL-Nummer (Logistik) *</label> <CardContent className="px-0">
<input name="erlNummer" value={form.erlNummer} onChange={handleChange} placeholder="z.B. ERL-00001" /> <form onSubmit={handleSubmit} className="space-y-4">
</div> <div className="space-y-2">
<div className="form-group"> <Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>
<label>Seriennummer *</label> <Input
<input name="seriennummer" value={form.seriennummer} onChange={handleChange} placeholder="z.B. SN-ABC123456" /> id="erlNummer"
</div> name="erlNummer"
<div className="form-group"> value={form.erlNummer}
<label>Artikelnummer</label> onChange={handleChange}
<input name="artikelNr" value={form.artikelNr} onChange={handleChange} placeholder="z.B. ART-20341" /> placeholder="z.B. ERL-00001"
</div> />
<div className="form-group"> </div>
<label>Bezeichnung</label>
<input name="bezeichnung" value={form.bezeichnung} onChange={handleChange} placeholder="z.B. Hydraulikpumpe XL" /> <div className="space-y-2">
</div> <Label htmlFor="seriennummer">Seriennummer *</Label>
<div className="form-group"> <Input
<label>Defektbeschreibung</label> id="seriennummer"
<textarea name="defekt" value={form.defekt} onChange={handleChange} placeholder="Was genau ist defekt? Wie sieht der Schaden aus?" /> name="seriennummer"
</div> value={form.seriennummer}
<div className="form-group"> onChange={handleChange}
<label>Lagerstandort</label> placeholder="z.B. SN-ABC123456"
<select name="lagerstandortId" value={form.lagerstandortId} onChange={handleChange}> />
<option value="">-- Standort wählen --</option> </div>
{(lagerstandorte || []).map((ls) => (
<option key={ls.$id} value={ls.$id}>{ls.name}</option> <div className="space-y-2">
))} <Label htmlFor="artikelNr">Artikelnummer</Label>
</select> <Input
</div> id="artikelNr"
<div className="form-group"> name="artikelNr"
<label>Zuständig *</label> value={form.artikelNr}
<select name="zustaendig" value={form.zustaendig} onChange={handleChange}> onChange={handleChange}
<option value="">-- Mitarbeiter wählen --</option> placeholder="z.B. ART-20341"
{(colleagues || []).map((c) => ( />
<option key={c.userId} value={c.userName}> </div>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</option> <div className="space-y-2">
))} <Label htmlFor="bezeichnung">Bezeichnung</Label>
</select> <Input
</div> id="bezeichnung"
<div className="form-group"> name="bezeichnung"
<label>Priorität *</label> value={form.bezeichnung}
<select name="prio" value={form.prio} onChange={handleChange}> onChange={handleChange}
<option value="niedrig">Niedrig</option> placeholder="z.B. Hydraulikpumpe XL"
<option value="mittel">Mittel</option> />
<option value="hoch">Hoch</option> </div>
<option value="kritisch">Kritisch</option>
</select> <div className="space-y-2">
</div> <Label htmlFor="defekt">Defektbeschreibung</Label>
<div className="form-group"> <Textarea
<label>Kommentar</label> id="defekt"
<textarea name="kommentar" value={form.kommentar} onChange={handleChange} placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)" /> name="defekt"
</div> value={form.defekt}
<button type="submit" className="btn-submit">Ware erfassen</button> onChange={handleChange}
</form> placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"
</div> />
</div>
<div className="space-y-2">
<Label>Lagerstandort</Label>
<Select value={form.lagerstandortId} onValueChange={(v) => setField('lagerstandortId', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Standort wählen" />
</SelectTrigger>
<SelectContent>
{(lagerstandorte || []).map((ls) => (
<SelectItem key={ls.$id} value={ls.$id}>
{ls.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Zuständig *</Label>
<Select value={form.zustaendig} onValueChange={(v) => setField('zustaendig', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Mitarbeiter wählen" />
</SelectTrigger>
<SelectContent>
{(colleagues || []).map((c) => (
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priorität *</Label>
<Select value={form.prio} onValueChange={(v) => setField('prio', v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priorität wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="mittel">Mittel</SelectItem>
<SelectItem value="hoch">Hoch</SelectItem>
<SelectItem value="kritisch">Kritisch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="kommentar">Kommentar</Label>
<Textarea
id="kommentar"
name="kommentar"
value={form.kommentar}
onChange={handleChange}
placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)"
/>
</div>
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700">
Ware erfassen
</Button>
</form>
</CardContent>
</Card>
); );
} }

View File

@@ -4,11 +4,21 @@ import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import CommentPopup from './CommentPopup'; import CommentPopup from './CommentPopup';
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter'; 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';
import { Printer, Package } from 'lucide-react';
const STATUS_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' }; const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
const NEXT_LABEL = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Entsorgen', entsorgt: '→ Neu öffnen' }; const NEXT_LABEL = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
const PRIO_ORDER = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 }; const PRIO_ORDER = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
const PRIO_COLORS = {
kritisch: 'bg-red-600',
hoch: 'bg-orange-500',
mittel: 'bg-yellow-500',
niedrig: 'bg-green-500',
};
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{ value: 'prio', label: 'Priorität' }, { value: 'prio', label: 'Priorität' },
@@ -23,6 +33,12 @@ const STATUS_OPTIONS = [
{ value: 'entsorgt', label: 'Entsorgt' }, { 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' },
entsorgt: { variant: 'secondary' },
};
function resolveStandortName(asset, lagerstandorte) { function resolveStandortName(asset, lagerstandorte) {
if (!asset.lagerstandortId) return ''; if (!asset.lagerstandortId) return '';
const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId); const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId);
@@ -160,108 +176,130 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
} }
const sortLabel = SORT_OPTIONS.find((o) => o.value === filters.sortBy)?.label || ''; const sortLabel = SORT_OPTIONS.find((o) => o.value === filters.sortBy)?.label || '';
const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name })); const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
return ( return (
<div className="table-card"> <Card className="py-0 gap-0">
<div className="table-toolbar"> <div className="flex items-center justify-between px-4 py-3 border-b">
<span className="table-result-count">{filtered.length} Assets</span> <span className="text-sm font-medium text-muted-foreground">
<button className="btn-print-small" onClick={handlePrint} title="Drucken">Drucken</button> {filtered.length} Assets
</span>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-3.5 w-3.5" />
Drucken
</Button>
</div> </div>
<div style={{ overflowX: 'auto' }}> <Table>
<table> <TableHeader>
<thead> <TableRow>
<tr> <ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}> <TextFilter value={filters.erlNummer} onChange={(v) => setFilter('erlNummer', v)} placeholder="ERL-Nummer suchen..." />
<TextFilter value={filters.erlNummer} onChange={(v) => setFilter('erlNummer', v)} placeholder="ERL-Nummer suchen..." /> </ColumnFilter>
</ColumnFilter>
<ColumnFilter label="Artikel" active={activeFilter === 'artikel'} summary={filters.artikel || null} onOpen={() => openFilter('artikel')} onClose={closeFilter}> <ColumnFilter label="Artikel" active={activeFilter === 'artikel'} summary={filters.artikel || null} onOpen={() => openFilter('artikel')} onClose={closeFilter}>
<TextFilter value={filters.artikel} onChange={(v) => setFilter('artikel', v)} placeholder="Artikelnr. oder Name..." /> <TextFilter value={filters.artikel} onChange={(v) => setFilter('artikel', v)} placeholder="Artikelnr. oder Name..." />
</ColumnFilter> </ColumnFilter>
<ColumnFilter label="Seriennr." active={activeFilter === 'seriennummer'} summary={filters.seriennummer || null} onOpen={() => openFilter('seriennummer')} onClose={closeFilter}> <ColumnFilter label="Seriennr." active={activeFilter === 'seriennummer'} summary={filters.seriennummer || null} onOpen={() => openFilter('seriennummer')} onClose={closeFilter}>
<TextFilter value={filters.seriennummer} onChange={(v) => setFilter('seriennummer', v)} placeholder="Seriennummer suchen..." /> <TextFilter value={filters.seriennummer} onChange={(v) => setFilter('seriennummer', v)} placeholder="Seriennummer suchen..." />
</ColumnFilter> </ColumnFilter>
<ColumnFilter label="Defekt" active={activeFilter === 'defekt'} summary={filters.defekt || null} onOpen={() => openFilter('defekt')} onClose={closeFilter}> <ColumnFilter label="Defekt" active={activeFilter === 'defekt'} summary={filters.defekt || null} onOpen={() => openFilter('defekt')} onClose={closeFilter}>
<TextFilter value={filters.defekt} onChange={(v) => setFilter('defekt', v)} placeholder="Defekt suchen..." /> <TextFilter value={filters.defekt} onChange={(v) => setFilter('defekt', v)} placeholder="Defekt suchen..." />
</ColumnFilter> </ColumnFilter>
<ColumnFilter label="Standort" active={activeFilter === 'standort'} summary={filters.standort ? lsMap[filters.standort] : null} onOpen={() => openFilter('standort')} onClose={closeFilter}> <ColumnFilter label="Standort" active={activeFilter === 'standort'} summary={filters.standort ? lsMap[filters.standort] : null} onOpen={() => openFilter('standort')} onClose={closeFilter}>
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} /> <SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
</ColumnFilter> </ColumnFilter>
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}> <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} /> <SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
</ColumnFilter> </ColumnFilter>
<ColumnFilter label="Suche nach" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}> <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} /> <SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
</ColumnFilter> </ColumnFilter>
<th>Aktionen</th> <TableHead>Aktionen</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody>
{filtered.map((a) => {
const days = getDaysOld(a.$createdAt);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
return ( <TableBody>
<tr key={a.$id} className={overdue ? 'overdue' : ''}> {filtered.map((a) => {
<td> const days = getDaysOld(a.$createdAt);
<span className={`prio-badge prio-${a.prio}`} /> const overdue = isOverdue(a);
<strong style={{ color: '#1565C0' }}>{a.erlNummer || ''}</strong> const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
</td> const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
<td>
<strong>{a.artikelNr}</strong><br /> return (
<span style={{ fontSize: '12px', color: '#555' }}>{a.bezeichnung}</span> <TableRow
</td> key={a.$id}
<td style={{ fontSize: '12px', fontFamily: 'monospace' }}>{a.seriennummer || ''}</td> className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
<td style={{ maxWidth: '180px', fontSize: '12px' }}>{a.defekt}</td> >
<td style={{ fontSize: '12px' }}>{resolveStandortName(a, lagerstandorte || [])}</td> <TableCell>
<td> <div className="flex items-center gap-2">
<span className={`badge badge-${STATUS_MAP[a.status]}`}>{STATUS_LABEL[a.status]}</span> <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
</td> <span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || ''}</span>
<td style={{ fontSize: '12px' }}> </div>
{ageText} </TableCell>
{overdue && <><br /><span className="age-warn">Überfällig!</span></>}
</td> <TableCell>
<td> <div className="font-medium">{a.artikelNr}</div>
<button className="btn-action btn-status" onClick={() => handleStatusChange(a.$id)}> <div className="text-xs text-muted-foreground">{a.bezeichnung}</div>
</TableCell>
<TableCell className="font-mono text-xs">{a.seriennummer || ''}</TableCell>
<TableCell className="max-w-[180px] text-xs truncate">{a.defekt}</TableCell>
<TableCell className="text-xs">{resolveStandortName(a, lagerstandorte || [])}</TableCell>
<TableCell>
<Badge variant={badgeCfg.variant} className={badgeCfg.className}>
{STATUS_LABEL[a.status]}
</Badge>
</TableCell>
<TableCell className="text-xs">
{ageText}
{overdue && (
<div className="text-amber-600 dark:text-amber-400 font-medium mt-0.5">Überfällig!</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Button variant="secondary" size="sm" onClick={() => handleStatusChange(a.$id)}>
{NEXT_LABEL[a.status]} {NEXT_LABEL[a.status]}
</button> </Button>
{a.kommentar && ( {a.kommentar && (
<button className="btn-action btn-info" onClick={() => setCommentAsset(a)}> <Button variant="outline" size="sm" onClick={() => setCommentAsset(a)}>
Info Info
</button> </Button>
)} )}
<button className="btn-action btn-edit-link" onClick={() => navigate(`/asset/${a.$id}`)}> <Button variant="default" size="sm" onClick={() => navigate(`/asset/${a.$id}`)}>
Bearbeiten Bearbeiten
</button> </Button>
</td> </div>
</tr> </TableCell>
); </TableRow>
})} );
</tbody> })}
</table> </TableBody>
</Table>
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="empty-state"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<div className="emoji">📦</div> <Package className="h-12 w-12 mb-3 opacity-30" />
<p>Keine Assets gefunden.</p> <p className="font-medium">Keine Assets gefunden.</p>
<p style={{ marginTop: '8px' }}>Passe die Filter an oder erfasse ein neues Asset.</p> <p className="text-sm mt-2">Passe die Filter an oder erfasse ein neues Asset.</p>
</div> </div>
)} )}
</div>
{commentAsset && ( {commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} /> <CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)} )}
</div> </Card>
); );
} }

View File

@@ -3,7 +3,6 @@ import Header from './Header';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import DefektForm from './DefektForm'; import DefektForm from './DefektForm';
import DefektTable from './DefektTable'; import DefektTable from './DefektTable';
import Toast from './Toast';
import { useAssets } from '../hooks/useAssets'; import { useAssets } from '../hooks/useAssets';
import { useAuditLog } from '../hooks/useAuditLog'; import { useAuditLog } from '../hooks/useAuditLog';
import { useLagerstandorte } from '../hooks/useLagerstandorte'; import { useLagerstandorte } from '../hooks/useLagerstandorte';
@@ -18,7 +17,7 @@ export default function DefektTrackApp() {
const { addLog } = useAuditLog(); const { addLog } = useAuditLog();
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId); const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
const { colleagues } = useColleagues(locationId); const { colleagues } = useColleagues(locationId);
const { toast, showToast } = useToast(); const { showToast } = useToast();
const userName = user?.name || user?.email || 'Unbekannt'; const userName = user?.name || user?.email || 'Unbekannt';
@@ -56,25 +55,52 @@ export default function DefektTrackApp() {
}, [assets, changeStatus, addLog, user, userName]); }, [assets, changeStatus, addLog, user, userName]);
return ( return (
<> <div className="flex h-screen flex-col overflow-hidden">
<Header assets={assets} showToast={showToast} /> <Header assets={assets} showToast={showToast} />
<Dashboard
assets={assets} <div className="flex flex-1 overflow-hidden">
lagerstandorte={lagerstandorte} {/* Sidebar fixed left */}
onAddLagerstandort={addLagerstandort} <aside className="hidden w-[380px] shrink-0 overflow-y-auto border-r bg-background p-4 md:block">
onToggleLagerstandort={toggleLagerstandort} <DefektForm
onDeleteLagerstandort={deleteLagerstandort} onAdd={handleAdd}
/> showToast={showToast}
<div className="main"> lagerstandorte={activeLagerstandorte}
<DefektForm onAdd={handleAdd} showToast={showToast} lagerstandorte={activeLagerstandorte} colleagues={colleagues} /> colleagues={colleagues}
<DefektTable />
assets={assets} </aside>
onChangeStatus={handleStatusChange}
showToast={showToast} {/* Main content scrollable */}
lagerstandorte={lagerstandorte} <main className="flex-1 overflow-x-hidden overflow-y-auto">
/> <div className="p-4">
<Dashboard
assets={assets}
lagerstandorte={lagerstandorte}
onAddLagerstandort={addLagerstandort}
onToggleLagerstandort={toggleLagerstandort}
onDeleteLagerstandort={deleteLagerstandort}
/>
</div>
<div className="px-4 pb-6">
<DefektTable
assets={assets}
onChangeStatus={handleStatusChange}
showToast={showToast}
lagerstandorte={lagerstandorte}
/>
</div>
{/* Mobile: form below table */}
<div className="p-4 md:hidden">
<DefektForm
onAdd={handleAdd}
showToast={showToast}
lagerstandorte={activeLagerstandorte}
colleagues={colleagues}
/>
</div>
</main>
</div> </div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} /> </div>
</>
); );
} }

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite'; import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite'; import { Query } from 'appwrite';
import Header from './Header'; import Header from './Header';
import Toast from './Toast'; import { useToast } from '@/hooks/useToast';
import { useToast } from '../hooks/useToast'; import { useAuth } from '@/context/AuthContext';
import { useAuth } from '../context/AuthContext'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
function getToday() { function getToday() {
const d = new Date(); const d = new Date();
@@ -41,7 +44,7 @@ function countInRange(assets, start, end) {
export default function FilialleiterDashboard() { export default function FilialleiterDashboard() {
const { userMeta } = useAuth(); const { userMeta } = useAuth();
const { toast, showToast } = useToast(); const { showToast } = useToast();
const locationId = userMeta?.locationId || ''; const locationId = userMeta?.locationId || '';
const [ownAssets, setOwnAssets] = useState([]); const [ownAssets, setOwnAssets] = useState([]);
@@ -104,111 +107,119 @@ export default function FilialleiterDashboard() {
}, [colleagues, ownAssets]); }, [colleagues, ownAssets]);
function trendArrow(current, previous) { function trendArrow(current, previous) {
if (current > previous) return { arrow: '▲', cls: 'trend-up' }; if (current > previous) return { arrow: '▲', cls: 'text-green-600' };
if (current < previous) return { arrow: '▼', cls: 'trend-down' }; if (current < previous) return { arrow: '▼', cls: 'text-red-600' };
return { arrow: '', cls: 'trend-flat' }; return { arrow: '', cls: 'text-muted-foreground' };
} }
const dayTrend = trendArrow(todayCount, yesterdayCount); const dayTrend = trendArrow(todayCount, yesterdayCount);
const monthTrend = trendArrow(thisMonthCount, lastMonthCount); const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
const comparisonMax = Math.max(ownTotal, avgAllFilialen, 1);
return ( return (
<> <>
<Header showToast={showToast} /> <Header showToast={showToast} />
<div className="panel-page"> <div className="mx-auto max-w-7xl p-6">
<div className="panel-title-bar"> <div className="mb-8">
<h1>Filialleiter Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight">Filialleiter Dashboard</h1>
<p>Tägliche und monatliche Übersicht deiner Filiale</p> <p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
</div> </div>
<div className="panel-stats"> <div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="panel-stat-card"> <Card>
<div className="panel-stat-number">{todayCount}</div> <CardContent className="pt-2">
<div className="panel-stat-label">Heute erfasst</div> <div className="text-3xl font-bold">{todayCount}</div>
<div className={`panel-trend ${dayTrend.cls}`}> <p className="text-sm text-muted-foreground">Heute erfasst</p>
{dayTrend.arrow} Gestern: {yesterdayCount} <p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
</div> {dayTrend.arrow} Gestern: {yesterdayCount}
</div> </p>
<div className="panel-stat-card"> </CardContent>
<div className="panel-stat-number">{thisMonthCount}</div> </Card>
<div className="panel-stat-label">Diesen Monat</div> <Card>
<div className={`panel-trend ${monthTrend.cls}`}> <CardContent className="pt-2">
{monthTrend.arrow} Letzter Monat: {lastMonthCount} <div className="text-3xl font-bold">{thisMonthCount}</div>
</div> <p className="text-sm text-muted-foreground">Diesen Monat</p>
</div> <p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
<div className="panel-stat-card"> {monthTrend.arrow} Letzter Monat: {lastMonthCount}
<div className="panel-stat-number">{ownTotal}</div> </p>
<div className="panel-stat-label">Meine Filiale</div> </CardContent>
</div> </Card>
<div className="panel-stat-card"> <Card>
<div className="panel-stat-number">{avgAllFilialen}</div> <CardContent className="pt-2">
<div className="panel-stat-label"> Alle Filialen</div> <div className="text-3xl font-bold">{ownTotal}</div>
</div> <p className="text-sm text-muted-foreground">Meine Filiale</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{avgAllFilialen}</div>
<p className="text-sm text-muted-foreground"> Alle Filialen</p>
</CardContent>
</Card>
</div> </div>
<div className="panel-comparison"> <Card className="mb-6">
<h2>Filialvergleich</h2> <CardHeader>
<div className="comparison-bars"> <CardTitle>Filialvergleich</CardTitle>
<div className="comparison-row"> </CardHeader>
<span className="comparison-label">Meine Filiale</span> <CardContent>
<div className="comparison-bar-bg"> <div className="space-y-4">
<div <div className="flex items-center gap-4">
className="comparison-bar own" <span className="w-32 shrink-0 text-sm font-medium">Meine Filiale</span>
style={{ width: `${Math.min(100, avgAllFilialen > 0 ? (ownTotal / avgAllFilialen) * 50 : 50)}%` }} <Progress value={Math.round((ownTotal / comparisonMax) * 100)} className="flex-1" />
/> <span className="w-12 text-right text-sm font-semibold tabular-nums">{ownTotal}</span>
</div> </div>
<span className="comparison-value">{ownTotal}</span> <div className="flex items-center gap-4">
</div> <span className="w-32 shrink-0 text-sm font-medium"> Durchschnitt</span>
<div className="comparison-row"> <Progress value={Math.round((avgAllFilialen / comparisonMax) * 100)} className="flex-1" />
<span className="comparison-label"> Durchschnitt</span> <span className="w-12 text-right text-sm font-semibold tabular-nums">{avgAllFilialen}</span>
<div className="comparison-bar-bg">
<div className="comparison-bar avg" style={{ width: '50%' }} />
</div> </div>
<span className="comparison-value">{avgAllFilialen}</span>
</div> </div>
</div> </CardContent>
</div> </Card>
<div className="panel-card" style={{ marginTop: 24 }}> <Card>
<h2>Mitarbeiter-Performance</h2> <CardHeader>
{employeeStats.length === 0 ? ( <CardTitle>Mitarbeiter-Performance</CardTitle>
<p className="panel-empty">Keine Mitarbeiter gefunden</p> </CardHeader>
) : ( <CardContent>
<div className="employee-table-wrap"> {employeeStats.length === 0 ? (
<table className="employee-table"> <p className="py-4 text-center text-sm text-muted-foreground">Keine Mitarbeiter gefunden</p>
<thead> ) : (
<tr> <Table>
<th>Mitarbeiter</th> <TableHeader>
<th>Zugewiesen</th> <TableRow>
<th>Offen</th> <TableHead>Mitarbeiter</TableHead>
<th>In Bearbeitung</th> <TableHead className="text-right">Zugewiesen</TableHead>
<th>Erledigt</th> <TableHead className="text-right">Offen</TableHead>
<th>Erledigungsrate</th> <TableHead className="text-right">In Bearbeitung</TableHead>
</tr> <TableHead className="text-right">Erledigt</TableHead>
</thead> <TableHead className="w-48">Erledigungsrate</TableHead>
<tbody> </TableRow>
</TableHeader>
<TableBody>
{employeeStats.map((e) => ( {employeeStats.map((e) => (
<tr key={e.name}> <TableRow key={e.name}>
<td><strong>{e.name}</strong></td> <TableCell className="font-medium">{e.name}</TableCell>
<td>{e.total}</td> <TableCell className="text-right tabular-nums">{e.total}</TableCell>
<td>{e.open}</td> <TableCell className="text-right tabular-nums">{e.open}</TableCell>
<td>{e.inProgress}</td> <TableCell className="text-right tabular-nums">{e.inProgress}</TableCell>
<td>{e.resolved}</td> <TableCell className="text-right tabular-nums">{e.resolved}</TableCell>
<td> <TableCell>
<div className="rate-bar-wrap"> <div className="flex items-center gap-2">
<div className="rate-bar" style={{ width: `${e.rate}%` }} /> <Progress value={e.rate} className="flex-1" />
<span className="rate-text">{e.rate}%</span> <span className="w-10 text-right text-xs font-medium tabular-nums">{e.rate}%</span>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> )}
)} </CardContent>
</div> </Card>
</div> </div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</> </>
); );
} }

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { databases, DATABASE_ID } from '../lib/appwrite'; import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite'; import { Query } from 'appwrite';
import Header from './Header'; import Header from './Header';
import Toast from './Toast'; import { useToast } from '@/hooks/useToast';
import { useToast } from '../hooks/useToast'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Building2, Users, Package, CheckCircle, AlertCircle, Clock, CircleCheck } from 'lucide-react';
export default function FirmenleiterDashboard() { export default function FirmenleiterDashboard() {
const { toast, showToast } = useToast(); const { showToast } = useToast();
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [allAssets, setAllAssets] = useState([]); const [allAssets, setAllAssets] = useState([]);
@@ -57,84 +59,139 @@ export default function FirmenleiterDashboard() {
return ( return (
<> <>
<Header showToast={showToast} /> <Header showToast={showToast} />
<div className="panel-page"> <div className="mx-auto max-w-7xl p-6">
<div className="panel-title-bar"> <div className="mb-6">
<h1>Firmenleiter Dashboard</h1> <h1 className="text-2xl font-bold tracking-tight">Firmenleiter Dashboard</h1>
<p>Übersicht aller Filialen</p> <p className="text-sm text-muted-foreground">Übersicht aller Filialen</p>
</div> </div>
<div className="panel-stats"> {/* Main stats */}
<div className="panel-stat-card"> <div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="panel-stat-number">{locations.length}</div> <Card>
<div className="panel-stat-label">Filialen</div> <CardContent className="flex items-center gap-3 pt-2">
</div> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<div className="panel-stat-card"> <Building2 className="h-5 w-5" />
<div className="panel-stat-number">{allUsers.length}</div> </div>
<div className="panel-stat-label">Mitarbeiter gesamt</div> <div>
</div> <p className="text-2xl font-bold">{locations.length}</p>
<div className="panel-stat-card"> <p className="text-xs text-muted-foreground">Filialen</p>
<div className="panel-stat-number">{totalAssets}</div> </div>
<div className="panel-stat-label">Assets gesamt</div> </CardContent>
</div> </Card>
<div className="panel-stat-card"> <Card>
<div className="panel-stat-number"> <CardContent className="flex items-center gap-3 pt-2">
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}% <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400">
</div> <Users className="h-5 w-5" />
<div className="panel-stat-label">Erledigungsrate</div> </div>
</div> <div>
<p className="text-2xl font-bold">{allUsers.length}</p>
<p className="text-xs text-muted-foreground">Mitarbeiter gesamt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
<Package className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{totalAssets}</p>
<p className="text-xs text-muted-foreground">Assets gesamt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}%
</p>
<p className="text-xs text-muted-foreground">Erledigungsrate</p>
</div>
</CardContent>
</Card>
</div> </div>
<div className="panel-stats" style={{ marginTop: 0 }}> {/* Status row */}
<div className="panel-stat-card small"> <div className="mb-6 grid grid-cols-3 gap-4">
<div className="panel-stat-number" style={{ color: '#C62828' }}>{totalOpen}</div> <Card>
<div className="panel-stat-label">Offen</div> <CardContent className="flex items-center gap-3 pt-2">
</div> <AlertCircle className="h-5 w-5 text-red-600" />
<div className="panel-stat-card small"> <div>
<div className="panel-stat-number" style={{ color: '#F9A825' }}>{totalInProgress}</div> <p className="text-xl font-bold text-red-600">{totalOpen}</p>
<div className="panel-stat-label">In Bearbeitung</div> <p className="text-xs text-muted-foreground">Offen</p>
</div> </div>
<div className="panel-stat-card small"> </CardContent>
<div className="panel-stat-number" style={{ color: '#43A047' }}>{totalResolved}</div> </Card>
<div className="panel-stat-label">Erledigt</div> <Card>
</div> <CardContent className="flex items-center gap-3 pt-2">
<Clock className="h-5 w-5 text-amber-600" />
<div>
<p className="text-xl font-bold text-amber-600">{totalInProgress}</p>
<p className="text-xs text-muted-foreground">In Bearbeitung</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-2">
<CircleCheck className="h-5 w-5 text-green-600" />
<div>
<p className="text-xl font-bold text-green-600">{totalResolved}</p>
<p className="text-xs text-muted-foreground">Erledigt</p>
</div>
</CardContent>
</Card>
</div> </div>
<div className="panel-card" style={{ marginTop: 24 }}> {/* Filialen section */}
<h2>Alle Filialen</h2> <Card>
{filialeStats.length === 0 ? ( <CardHeader>
<p className="panel-empty">Keine Filialen vorhanden</p> <CardTitle>Alle Filialen</CardTitle>
) : ( </CardHeader>
<div className="filiale-grid"> <CardContent>
{filialeStats.map((f) => ( {filialeStats.length === 0 ? (
<div key={f.id} className={`filiale-card ${f.isActive ? '' : 'inactive'}`}> <p className="text-sm text-muted-foreground">Keine Filialen vorhanden</p>
<div className="filiale-card-header"> ) : (
<h3>{f.name}</h3> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<span className={`panel-badge ${f.isActive ? 'active' : 'inactive'}`}> {filialeStats.map((f) => (
{f.isActive ? 'Aktiv' : 'Inaktiv'} <Card key={f.id} className={f.isActive ? '' : 'opacity-60'}>
</span> <CardHeader className="pb-2">
</div> <div className="flex items-center justify-between">
{f.address && <p className="filiale-address">{f.address}</p>} <CardTitle className="text-base">{f.name}</CardTitle>
<div className="filiale-stats-row"> <Badge variant={f.isActive ? 'default' : 'secondary'}>
<div className="filiale-mini-stat"> {f.isActive ? 'Aktiv' : 'Inaktiv'}
<span className="filiale-mini-num">{f.userCount}</span> </Badge>
<span className="filiale-mini-label">Mitarbeiter</span> </div>
</div> {f.address && (
<div className="filiale-mini-stat"> <p className="text-xs text-muted-foreground">{f.address}</p>
<span className="filiale-mini-num">{f.lsCount}</span> )}
<span className="filiale-mini-label">Lagerstandorte</span> </CardHeader>
</div> <CardContent>
<div className="filiale-mini-stat"> <div className="flex items-center gap-4 text-sm">
<span className="filiale-mini-num">{f.assetsTotal}</span> <div className="flex flex-col items-center">
<span className="filiale-mini-label">Assets</span> <span className="text-lg font-semibold">{f.userCount}</span>
</div> <span className="text-xs text-muted-foreground">Mitarbeiter</span>
</div> </div>
</div> <div className="flex flex-col items-center">
))} <span className="text-lg font-semibold">{f.lsCount}</span>
</div> <span className="text-xs text-muted-foreground">Lagerstandorte</span>
)} </div>
</div> <div className="flex flex-col items-center">
<span className="text-lg font-semibold">{f.assetsTotal}</span>
<span className="text-xs text-muted-foreground">Assets</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</> </>
); );
} }

View File

@@ -1,6 +1,19 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Download, LogOut, User, ChevronDown } from 'lucide-react';
const ROLE_LABELS = { const ROLE_LABELS = {
admin: 'Admin', admin: 'Admin',
@@ -57,41 +70,81 @@ export default function Header({ assets, showToast }) {
const isOnFirmenleiter = loc.pathname === '/firmenleiter'; const isOnFirmenleiter = loc.pathname === '/firmenleiter';
return ( return (
<header> <header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background/95 px-5 py-2.5 backdrop-blur-sm">
<div> <div className="flex items-center gap-3">
<div className="logo"> <span className="text-lg font-bold tracking-tight">
Defekt<span>Track</span> Defekt<span className="text-amber-500">Track</span>
{locationName && <span className="logo-location"> · {locationName}</span>} </span>
</div> {locationName && (
<div className="header-sub">Lager &amp; Logistik · Defekte Ware im Griff by Justin Klein</div> <>
</div> <Separator orientation="vertical" className="!h-5" />
<div className="header-buttons"> <span className="text-sm text-muted-foreground">{locationName}</span>
{user && ( </>
<span className="header-user-info">
{user.name || user.email}
<span className="header-role-badge">{ROLE_LABELS[role] || role}</span>
</span>
)} )}
</div>
<nav className="header-nav"> <div className="flex items-center gap-1">
<nav className="flex items-center gap-0.5">
{!isOnTracker && ( {!isOnTracker && (
<button className="btn-header btn-nav" onClick={() => navigate('/tracker')}>DefektTrack</button> <Button variant="ghost" size="sm" onClick={() => navigate('/tracker')}>
DefektTrack
</Button>
)} )}
{isAdmin && !isOnAdmin && ( {isAdmin && !isOnAdmin && (
<button className="btn-header btn-nav" onClick={() => navigate('/admin')}>Admin Panel</button> <Button variant="ghost" size="sm" onClick={() => navigate('/admin')}>
Admin
</Button>
)} )}
{(isFilialleiter || isAdmin) && !isOnFilialleiter && ( {(isFilialleiter || isAdmin) && !isOnFilialleiter && (
<button className="btn-header btn-nav" onClick={() => navigate('/filialleiter')}>Filialleiter</button> <Button variant="ghost" size="sm" onClick={() => navigate('/filialleiter')}>
Filialleiter
</Button>
)} )}
{(isFirmenleiter || isAdmin) && !isOnFirmenleiter && ( {(isFirmenleiter || isAdmin) && !isOnFirmenleiter && (
<button className="btn-header btn-nav" onClick={() => navigate('/firmenleiter')}>Firmenleiter</button> <Button variant="ghost" size="sm" onClick={() => navigate('/firmenleiter')}>
Firmenleiter
</Button>
)} )}
{isOnTracker && assets && ( {isOnTracker && assets && (
<button className="btn-header btn-export" onClick={handleExport}>Export</button> <Button variant="outline" size="sm" onClick={handleExport}>
<Download className="mr-1.5 h-3.5 w-3.5" />
Export
</Button>
)} )}
</nav> </nav>
<button className="btn-header btn-logout" onClick={handleLogout}>Logout</button> {user && (
<>
<Separator orientation="vertical" className="!h-5 mx-1.5" />
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline">{user.name || user.email}</span>
<Badge variant="outline" className="ml-0.5 text-[10px] font-medium">
{ROLE_LABELS[role] || role}
</Badge>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{user.name || user.email}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div> </div>
</header> </header>
); );

View File

@@ -1,4 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
export default function LagerstandortManager({ lagerstandorte, onAdd, onToggle, onDelete, onClose }) { export default function LagerstandortManager({ lagerstandorte, onAdd, onToggle, onDelete, onClose }) {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -17,48 +28,61 @@ export default function LagerstandortManager({ lagerstandorte, onAdd, onToggle,
} }
return ( return (
<> <Dialog open={true} onOpenChange={onClose}>
<div className="comment-overlay" onClick={onClose} /> <DialogContent className="sm:max-w-[550px]">
<div className="comment-popup" style={{ maxWidth: '550px' }}> <DialogHeader>
<h3>Lagerstandorte verwalten</h3> <DialogTitle>Lagerstandorte verwalten</DialogTitle>
<DialogDescription className="sr-only">
Lagerstandorte hinzufügen, aktivieren oder löschen
</DialogDescription>
</DialogHeader>
<form className="lsm-add-row" onSubmit={handleAdd}> <form onSubmit={handleAdd} className="flex items-center gap-2">
<input <Input
className="lsm-input"
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="Neuer Standort (z.B. Regal B-12)" placeholder="Neuer Standort (z.B. Regal B-12)"
className="flex-1"
/> />
<button type="submit" className="lsm-btn-add" disabled={adding || !newName.trim()}> <Button type="submit" disabled={adding || !newName.trim()} className="bg-amber-600 hover:bg-amber-700">
{adding ? '...' : 'Hinzufügen'} {adding ? '...' : 'Hinzufügen'}
</button> </Button>
</form> </form>
<div className="lsm-list"> <Separator />
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{lagerstandorte.length === 0 && ( {lagerstandorte.length === 0 && (
<p className="lsm-empty">Noch keine Lagerstandorte angelegt.</p> <p className="text-sm text-muted-foreground text-center py-4">
Noch keine Lagerstandorte angelegt.
</p>
)} )}
{lagerstandorte.map((ls) => ( {lagerstandorte.map((ls) => (
<div key={ls.$id} className={`lsm-item ${ls.isActive ? '' : 'inactive'}`}> <div
<span className="lsm-name">{ls.name}</span> key={ls.$id}
<div className="lsm-actions"> className="flex items-center justify-between rounded-md border px-3 py-2"
<button >
className={`btn-action ${ls.isActive ? 'btn-status' : 'btn-info'}`} <div className="flex items-center gap-2">
onClick={() => onToggle(ls.$id)} <span className={`text-sm font-medium ${ls.isActive ? '' : 'text-muted-foreground line-through'}`}>
> {ls.name}
</span>
<Badge variant={ls.isActive ? 'default' : 'secondary'}>
{ls.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onToggle(ls.$id)}>
{ls.isActive ? 'Deaktivieren' : 'Aktivieren'} {ls.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button> </Button>
<button className="btn-action btn-delete" onClick={() => onDelete(ls.$id)}> <Button variant="destructive" size="sm" onClick={() => onDelete(ls.$id)}>
Löschen Löschen
</button> </Button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</DialogContent>
<button className="close-btn" onClick={onClose}>Schließen</button> </Dialog>
</div>
</>
); );
} }

View File

@@ -1,6 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { AlertCircle, Loader2 } from 'lucide-react';
export default function Login() { export default function Login() {
const { login } = useAuth(); const { login } = useAuth();
@@ -35,40 +40,54 @@ export default function Login() {
} }
return ( return (
<div className="login-page"> <div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="login-card"> <Card className="w-full max-w-md shadow-lg">
<div className="login-header"> <CardHeader className="text-center space-y-1">
<div className="logo">Defekt<span>Track</span></div> <CardTitle className="text-3xl font-bold tracking-tight">
<p className="login-subtitle">Lager &amp; Logistik · Defekte Ware im Griff</p> Defekt<span className="text-amber-500">Track</span>
</div> </CardTitle>
<form className="login-form" onSubmit={handleSubmit}> <CardDescription>
<div className="form-group"> Lager &amp; Logistik · Defekte Ware im Griff
<label>E-Mail</label> </CardDescription>
<input </CardHeader>
type="email" <CardContent>
value={email} <form onSubmit={handleSubmit} className="space-y-4">
onChange={(e) => setEmail(e.target.value)} <div className="space-y-2">
placeholder="name@firma.de" <Label htmlFor="email">E-Mail</Label>
autoComplete="email" <Input
autoFocus id="email"
/> type="email"
</div> value={email}
<div className="form-group"> onChange={(e) => setEmail(e.target.value)}
<label>Passwort</label> placeholder="name@firma.de"
<input autoComplete="email"
type="password" autoFocus
value={password} />
onChange={(e) => setPassword(e.target.value)} </div>
placeholder="Passwort eingeben" <div className="space-y-2">
autoComplete="current-password" <Label htmlFor="password">Passwort</Label>
/> <Input
</div> id="password"
{error && <div className="login-error">{error}</div>} type="password"
<button type="submit" className="btn-submit" disabled={loading}> value={password}
{loading ? 'Anmelden...' : 'Anmelden'} onChange={(e) => setPassword(e.target.value)}
</button> placeholder="Passwort eingeben"
</form> autoComplete="current-password"
</div> />
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? 'Anmelden...' : 'Anmelden'}
</Button>
</form>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,16 +1,26 @@
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function ProtectedRoute({ children }) { export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) { if (loading) {
return ( return (
<div className="login-page"> <div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="login-card" style={{ textAlign: 'center' }}> <Card className="w-full max-w-md shadow-lg">
<div className="logo" style={{ marginBottom: '12px' }}>Defekt<span>Track</span></div> <CardContent className="flex flex-col items-center gap-4 py-10">
<p style={{ color: '#888' }}>Lade...</p> <p className="text-2xl font-bold tracking-tight">
</div> Defekt<span className="text-amber-500">Track</span>
</p>
<div className="w-full space-y-3">
<Skeleton className="h-4 w-3/4 mx-auto" />
<Skeleton className="h-4 w-1/2 mx-auto" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,10 +0,0 @@
export default function Toast({ message, color, visible }) {
return (
<div
className={`toast ${visible ? 'show' : ''}`}
style={{ background: color }}
>
{message}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}) {
return useRender({
defaultTagName: "span",
props: mergeProps({
className: cn(badgeVariants({ variant }), className),
}, props),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants }

114
src/components/ui/card.jsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} />
);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />
}>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({
...props
}) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props} />
);
}
function DropdownMenuSub({
...props
}) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props} />
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}) {
return (<MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function DropdownMenuShortcut({
className,
...props
}) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props} />
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Input }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props} />
);
}
export { Label }

View File

@@ -0,0 +1,89 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50">
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({
className,
...props
}) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props} />
);
}
function PopoverTitle({
className,
...props
}) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props} />
);
}
function PopoverDescription({
className,
...props
}) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props} />
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,84 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
function ProgressTrack({
className,
...props
}) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props} />
);
}
function ProgressIndicator({
className,
...props
}) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props} />
);
}
function ProgressLabel({
className,
...props
}) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props} />
);
}
function ProgressValue({
className,
...props
}) {
return (
<ProgressPrimitive.Value
className={cn("ml-auto text-sm text-muted-foreground tabular-nums", className)}
data-slot="progress-value"
{...props} />
);
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({
className,
...props
}) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props} />
);
}
function SelectValue({
className,
...props
}) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50">
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props} />
);
}
function SelectItem({
className,
children,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span
className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,22 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props} />
);
}
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} />
);
}
export { Skeleton }

View File

@@ -0,0 +1,48 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner";
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)"
}
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props} />
);
}
export { Toaster }

123
src/components/ui/table.jsx Normal file
View File

@@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({
className,
...props
}) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
);
}
function TableHeader({
className,
...props
}) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props} />
);
}
function TableBody({
className,
...props
}) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
);
}
function TableFooter({
className,
...props
}) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
);
}
function TableRow({
className,
...props
}) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
);
}
function TableHead({
className,
...props
}) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCell({
className,
...props
}) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCaption({
className,
...props
}) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({
className,
...props
}) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />);
}
function Tooltip({
...props
}) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50">
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
<TooltipPrimitive.Arrow
className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,16 +1,16 @@
import { useState, useCallback, useRef } from 'react'; import { useCallback } from 'react';
import { toast } from 'sonner';
export function useToast() { export function useToast() {
const [toast, setToast] = useState({ message: '', color: '#2E7D32', visible: false }); const showToast = useCallback((message, color) => {
const timerRef = useRef(null); if (color === '#C62828' || color === 'error') {
toast.error(message);
const showToast = useCallback((message, color = '#2E7D32') => { } else if (color === '#607D8B' || color === 'info') {
if (timerRef.current) clearTimeout(timerRef.current); toast.info(message);
setToast({ message, color, visible: true }); } else {
timerRef.current = setTimeout(() => { toast.success(message);
setToast((prev) => ({ ...prev, visible: false })); }
}, 2800);
}, []); }, []);
return { toast, showToast }; return { showToast };
} }

View File

@@ -1,10 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Toaster } from '@/components/ui/sonner'
import App from './App.jsx' import App from './App.jsx'
import './App.css' import './App.css'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
<Toaster richColors position="bottom-right" />
</StrictMode>, </StrictMode>,
) )

187
todos.md
View File

@@ -6,162 +6,179 @@ Ziel: Aufbau eines sicheren, rollenbasierten Defekt- und Retouren-Management-Sys
# PRIORITY 1 CORE SECURITY & ACCESS # PRIORITY 1 CORE SECURITY & ACCESS
## 1. Login-Gate vor dem Laden der App - [x] ## 1. Login-Gate vor dem Laden der App
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat. Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
## 2. Session-System (Login bleibt bis Browser geschlossen wird) - [x] ## 2. Session-System (Login bleibt bis Browser geschlossen wird)
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen. Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
## 3. Benutzerverwaltung (Admin) - [x] ## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können. Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
## 4. Rollen-System - [x] ## 4. Rollen-System
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind. Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
## 5. Startpasswort-System - [ ] ## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss. Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
## 6. Passwort-Änderungspflicht - [ ] ## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst. Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
## 7. PIN-Login-System - [ ] ## 7. PIN-Login-System
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt. Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
## 8. Passwort-Hashing - [x] ## 8. Passwort-Hashing
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden. Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden (Appwrite übernimmt dies).
## 9. Zugriffskontrolle (Role Based Access Control) - [x] ## 9. Zugriffskontrolle (Role Based Access Control)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen. Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
## 10. Audit-Log - [x] ## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden. Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
--- ---
# PRIORITY 2 USER EXPERIENCE & DASHBOARDS # PRIORITY 2 USER EXPERIENCE & DASHBOARDS
## 11. Rollenbasierte Startseiten - [x] ## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle. Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
## 12. Lagerkraft-Startseite - [x] ## 12. Lagerkraft-Startseite
Zeigt primär offene Artikel und operative Aufgaben. Zeigt primär offene Artikel und operative Aufgaben (Tracker).
## 13. Service-Startseite - [x] ## 13. Service-Startseite
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare. Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare (Tracker).
## 14. Filialleiter-Dashboard - [x] ## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale. Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
## 15. Firmenleiter-Dashboard - [x] ## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten. Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
## 16. Automatische Filter je Rolle - [ ] ## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst). Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
--- ---
# PRIORITY 3 DEFECT MANAGEMENT CORE # PRIORITY 3 DEFECT MANAGEMENT CORE
## 17. Defektfall-System - [x] ## 17. Defektfall-System
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen. Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen (Assets-Collection).
## 18. Status-Workflow - [x] ## 18. Status-Workflow
Statussystem für Fälle (Offen → In Bearbeitung → Erledigt → Entsorgt). Statussystem für Fälle (Offen → In Bearbeitung → Entsorgt).
## 19. Prioritätssystem - [x] ## 19. Prioritätssystem
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch). Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
## 20. Verantwortlichkeits-System - [x] ## 20. Verantwortlichkeits-System
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden. Jeder Defektfall muss einem Mitarbeiter zugewiesen werden (Zuständig-Dropdown aus Appwrite-Benutzern der Filiale).
## 21. Kommentar-System - [x] ## 21. Kommentar-System
Interne Kommentare und technische Notizen zu jedem Defektfall. Interne Kommentare und technische Notizen zu jedem Defektfall (Kommentar-Feld, CommentPopup für Anzeige).
## 22. Defekt-Historie - [x] ## 22. Defekt-Historie
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden. Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden (Audit-Log pro Asset).
--- ---
# PRIORITY 4 SEARCH & FILTERING # PRIORITY 4 SEARCH & FILTERING
## 23. Erweiterte Suche - [x] ## 23. Erweiterte Suche
Suche nach ERL-Nummer, Seriennummer, Artikelnummer oder Beschreibung. Suche nach ERL-Nummer, Artikelnummer, Seriennummer, Defektbeschreibung.
## 24. Statusfilter - [x] ## 24. Statusfilter
Filter für offene, in Bearbeitung befindliche, erledigte oder entsorgte Artikel. Filter für offene, in Bearbeitung befindliche, entsorgte Artikel.
## 25. Prioritätsfilter - [x] ## 25. Prioritätsfilter
Filter für kritische oder wichtige Fälle. Filter/Sortierung nach Priorität (kritisch, hoch, mittel, niedrig).
## 26. Mitarbeiterfilter - [x] ## 26. Mitarbeiterfilter
Anzeige der Fälle nach zuständigem Mitarbeiter. Anzeige der Fälle nach zuständigem Mitarbeiter (Sortierung „Mir zugewiesen“).
--- ---
# PRIORITY 5 STATISTICS & ANALYTICS # PRIORITY 5 STATISTICS & ANALYTICS
## 27. Mitarbeiterstatistiken - [x] ## 27. Mitarbeiterstatistiken
Eigene offenen, erledigten und überfälligen Fälle eines Mitarbeiters. Eigene offenen, erledigten und überfälligen Fälle (Filialleiter-Dashboard: Mitarbeiter-Performance mit Erledigungsrate).
## 28. Filialstatistiken - [x] ## 28. Filialstatistiken
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale. Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
## 29. Unternehmensstatistiken - [x] ## 29. Unternehmensstatistiken
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen. Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen (Firmenleiter-Dashboard).
## 30. Bearbeitungszeit-Analyse - [ ] ## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls. Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
## 31. Häufigste Defekte - [ ] ## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme. Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
--- ---
# PRIORITY 6 ORGANISATION STRUCTURE # PRIORITY 6 ORGANISATION STRUCTURE
## 32. Filial-System - [x] ## 32. Filial-System
Unterstützung mehrerer Standorte innerhalb eines Unternehmens. Unterstützung mehrerer Standorte innerhalb eines Unternehmens (locations-Collection, Admin verwaltet Filialen).
## 33. Standortzuweisung für Benutzer - [x] ## 33. Standortzuweisung für Benutzer
Benutzer gehören zu einer bestimmten Filiale. Benutzer gehören zu einer bestimmten Filiale (users_meta.locationId).
## 34. Standortfilter für Daten - [x] ## 34. Standortfilter für Daten
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten. Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
--- ---
# PRIORITY 7 SYSTEM FEATURES # PRIORITY 7 SYSTEM FEATURES
## 35. Export-Funktion - [x] ## 35. Export-Funktion
Datenexport für Berichte oder Archivierung. Datenexport für Berichte oder Archivierung (JSON-Export im Header).
## 36. Import-Funktion - [ ] ## 36. Import-Funktion
Import von Datensätzen für Migration oder Synchronisation. Import von Datensätzen für Migration oder Synchronisation.
## 37. Druckansicht - [x] ## 37. Druckansicht
Optimierte Druckansicht für Berichte oder Listen. Optimierte Druckansicht für Berichte oder Listen (Drucken-Button in der Asset-Tabelle).
## 38. Benachrichtigungen - [ ] ## 38. Benachrichtigungen
Systemmeldungen bei kritischen oder überfälligen Defektfällen. Systemmeldungen bei kritischen oder überfälligen Defektfällen (Toasts vorhanden, keine gezielten Alerts).
--- ---
# PRIORITY 8 FUTURE FEATURES # PRIORITY 8 FUTURE FEATURES
## 39. Datei-Uploads - [ ] ## 39. Datei-Uploads
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen. Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
## 40. Mobile Optimierung - [x] ## 40. Mobile Optimierung
Optimierte Nutzung für Tablets oder mobile Geräte im Lager. Optimierte Nutzung für Tablets oder mobile Geräte im Lager (responsive Layout, Sidebar ausgeblendet auf Mobile, Form unten).
## 41. API-Schnittstellen - [ ] ## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen. Möglichkeit zur Integration mit anderen Systemen.
## 42. Automatische Eskalationen - [ ] ## 42. Automatische Eskalationen
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben. Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
---
# ERGÄNZUNGEN (bereits umgesetzt, nicht im Original-Roadmap)
- [x] **Appwrite-Integration** Auth, Teams, Databases; Collections: locations, users_meta, lagerstandorte, assets, audit_logs.
- [x] **Produkte → Assets** Umbenennung, eigene Collection, Verknüpfung mit Lagerstandort und Location.
- [x] **Lagerstandorte** Pro Filiale mehrere Lagerstandorte, verwaltbar über Button im Dashboard (LagerstandortManager).
- [x] **Asset-Detailseite** Eigene Seite `/asset/:id` mit allen Eigenschaften, Bearbeiten-Modus, Audit-Log (Konsolen-Style).
- [x] **Bearbeiten statt Löschen** Button „Bearbeiten“ öffnet Asset-Detailseite.
- [x] **Header** Standortname neben DefektTrack, Navigation Admin / Filialleiter / Firmenleiter, User-Dropdown mit Logout, Export.
- [x] **Admin: Filialen verwalten** Filialen anlegen, bearbeiten, aktivieren/deaktivieren, löschen.
- [x] **Zuständig-Dropdown** Default eigener Name, Auswahl nur Mitarbeiter der gleichen Filiale (Appwrite).
- [x] **Audit-Log Erfassung** Bei Erstellung: „für sich selbst erfasst“ vs. „von X für Y erfasst“; bei Bearbeitung und Statusänderung.
- [x] **UI-Redesign mit shadcn/ui** Button, Input, Card, Table, Badge, Dialog, Select, Sonner-Toast, einheitliches Design.
- [x] **Header clean/minimal** Heller Header, keine horizontale Scrollbar, User-Dropdown mit DropdownMenuGroup-Fix.
- [x] **Layout: fixe Sidebar links** „Defekte Ware erfassen“ als fixe linke Sidebar (380px), rechts Status-Karten + Asset-Tabelle, kein horizontaler Scroll.
--- ---
# PROJECT GOAL # PROJECT GOAL
Ein sicheres, rollenbasiertes System zur Verwaltung defekter Artikel in Lager, Service und Management mit klaren Verantwortlichkeiten, Nachverfolgbarkeit und statistischen Auswertungen. Ein sicheres, rollenbasiertes System zur Verwaltung defekter Artikel in Lager, Service und Management mit klaren Verantwortlichkeiten, Nachverfolgbarkeit und statistischen Auswertungen.