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",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0"
@@ -6163,6 +6166,16 @@
"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": {
"version": "22.1.3",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz",
@@ -6915,6 +6928,57 @@
"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": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -7278,6 +7342,12 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7490,6 +7560,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"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": {
"version": "0.6.1",
"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",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.14",
"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 { databases, DATABASE_ID } from '../lib/appwrite';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { ID, Query } from 'appwrite';
import Header from './Header';
import Toast from './Toast';
import { useToast } from '../hooks/useToast';
import { useAuth } from '../context/AuthContext';
import { useToast } from '@/hooks/useToast';
import { useAuth } from '@/context/AuthContext';
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() {
const { user, userMeta } = useAuth();
const { toast, showToast } = useToast();
const { showToast } = useToast();
const locationId = userMeta?.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 (
<>
<Header showToast={showToast} />
<div className="panel-page">
<div className="panel-title-bar">
<h1>Admin Panel</h1>
<p>System-Übersicht und Verwaltung</p>
<div className="mx-auto max-w-7xl p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Admin Panel</h1>
<p className="mt-1 text-muted-foreground">System-Übersicht und Verwaltung</p>
</div>
<div className="panel-stats">
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.users}</div>
<div className="panel-stat-label">Benutzer</div>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{stats.locations}</div>
<div className="panel-stat-label">Filialen</div>
</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 className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
{statItems.map((item) => (
<Card key={item.label}>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{item.value}</div>
<p className="text-sm text-muted-foreground">{item.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="panel-grid">
<div className="panel-card">
<h2>Filialen verwalten</h2>
<div className="grid gap-6 lg:grid-cols-2">
{/* Filialen */}
<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}>
<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>
<Separator className="my-4" />
<div className="panel-list" style={{ marginTop: 16 }}>
{locations.length === 0 && <p className="panel-empty">Keine Filialen vorhanden</p>}
{locations.map((loc) => (
<div key={loc.$id} className={`filiale-admin-item ${loc.isActive ? '' : 'inactive'}`}>
{editingId === loc.$id ? (
<div className="filiale-edit-row">
<input
type="text"
className="filiale-input"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname"
/>
<input
type="text"
className="filiale-input"
value={editForm.address}
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse"
/>
<div className="filiale-edit-btns">
<button className="btn-action btn-status" onClick={handleSaveEdit}>Speichern</button>
<button className="btn-action btn-info" onClick={() => setEditingId(null)}>Abbrechen</button>
<div className="space-y-3">
{locations.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Keine Filialen vorhanden</p>
)}
{locations.map((loc) => (
<div
key={loc.$id}
className={`flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between ${!loc.isActive ? 'opacity-60' : ''}`}
>
{editingId === loc.$id ? (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Filialname"
className="flex-1"
/>
<Input
value={editForm.address}
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse"
className="flex-1"
/>
<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 className="filiale-admin-info">
<strong>{loc.name}</strong>
{loc.address && <span className="panel-list-sub">{loc.address}</span>}
<span className={`panel-badge ${loc.isActive ? 'active' : 'inactive'}`}>
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div className="filiale-admin-actions">
<button className="btn-action btn-info" onClick={() => startEdit(loc)}>Bearbeiten</button>
<button className="btn-action btn-status" onClick={() => handleToggleFiliale(loc.$id)}>
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button className="btn-action btn-delete" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</button>
</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 className="flex items-center gap-3">
<span className="font-medium">{loc.name}</span>
{loc.address && <span className="text-sm text-muted-foreground">{loc.address}</span>}
<Badge variant={loc.isActive ? 'default' : 'outline'}>
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => startEdit(loc)}>Bearbeiten</Button>
<Button size="sm" variant="secondary" onClick={() => handleToggleFiliale(loc.$id)}>
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</Button>
</div>
</>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<div className="panel-card">
<h2>Lagerstandorte</h2>
<button className="btn-panel-action" onClick={() => setShowLsManager(true)}>
Lagerstandorte verwalten
</button>
<div className="panel-list" style={{ marginTop: 12 }}>
{lagerstandorte.map((l) => (
<div key={l.$id} className="panel-list-item">
<span>{l.name}</span>
<span className={`panel-badge ${l.isActive ? 'active' : 'inactive'}`}>
{l.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
))}
</div>
</div>
{/* Benutzer */}
<Card>
<CardHeader>
<CardTitle>Benutzer</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{usersList.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Keine Benutzer vorhanden</p>
)}
{usersList.map((u) => {
const loc = locations.find((l) => l.$id === u.locationId);
return (
<div key={u.$id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<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>
@@ -262,7 +282,6 @@ export default function AdminPanel() {
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 { useParams, useNavigate } from 'react-router-dom';
import { databases, DATABASE_ID } from '../lib/appwrite';
import { useAuth } from '../context/AuthContext';
import { useAuditLog } from '../hooks/useAuditLog';
import { useLagerstandorte } from '../hooks/useLagerstandorte';
import { useColleagues } from '../hooks/useColleagues';
import { getDaysOld, isOverdue } from '../hooks/useAssets';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { useAuth } from '@/context/AuthContext';
import { useAuditLog } from '@/hooks/useAuditLog';
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
import { useColleagues } from '@/hooks/useColleagues';
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_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
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' });
}
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() {
const { id } = useParams();
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) {
return (
<div className="asset-detail-page">
<div className="asset-detail-loading">Lade Asset</div>
<div className="mx-auto flex max-w-4xl flex-col items-center gap-4 p-6 pt-24">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (!asset) {
return (
<div className="asset-detail-page">
<div className="asset-detail-not-found">
<h2>Asset nicht gefunden</h2>
<p>Das Asset mit der ID <code>{id}</code> existiert nicht.</p>
<button className="btn-back" onClick={() => navigate('/tracker')}>Zurück zur Übersicht</button>
</div>
<div className="mx-auto max-w-4xl p-6 pt-12">
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Asset nicht gefunden</h2>
<p className="text-sm text-muted-foreground">
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>
);
}
@@ -165,154 +217,193 @@ export default function AssetDetail() {
const overdue = isOverdue(asset);
return (
<div className="asset-detail-page">
<div className="asset-detail-header">
<button className="btn-back" onClick={() => navigate('/tracker')}> Zurück</button>
<h1>
Asset: <span style={{ color: '#1565C0' }}>{asset.erlNummer || ''}</span>
<div className="mx-auto max-w-4xl p-6">
{/* Back button */}
<Button variant="outline" className="mb-4" onClick={() => navigate('/tracker')}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
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>
<div className="asset-detail-meta">
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
{overdue && <span className="age-warn">Überfällig ({days} Tage)</span>}
</div>
<StatusBadge status={asset.status} />
<PrioBadge prio={asset.prio} />
{overdue && (
<Badge variant="destructive" className="text-xs">
Überfällig ({days} Tage)
</Badge>
)}
</div>
<div className="asset-detail-card">
<div className="asset-detail-card-header">
<h2>Eigenschaften</h2>
{!editing ? (
<button className="btn-edit" onClick={() => setEditing(true)}>Bearbeiten</button>
) : (
<div className="edit-actions">
<button className="btn-save" onClick={handleSave} disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
<button className="btn-cancel" onClick={() => { 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 || '',
}); }}>Abbrechen</button>
{/* Properties card */}
<Card className="mb-6">
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Eigenschaften</CardTitle>
<div className="flex gap-2">
{!editing ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Bearbeiten
</Button>
) : (
<>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="mr-1.5 h-3.5 w-3.5" />
{saving ? 'Speichern…' : 'Speichern'}
</Button>
<Button variant="outline" size="sm" onClick={resetForm}>
<X className="mr-1.5 h-3.5 w-3.5" />
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 className="asset-props-grid">
<PropertyRow label="ERL-Nr." value={form.erlNummer} field="erlNummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
<PropertyRow label="Artikelnr." value={form.artikelNr} field="artikelNr" editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
<PropertyRow label="Bezeichnung" value={form.bezeichnung} field="bezeichnung" editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
<PropertyRow label="Seriennummer" value={form.seriennummer} field="seriennummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
<PropertyRow label="Defekt" value={form.defekt} field="defekt" editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea />
<div className="prop-row">
<span className="prop-label">Lagerstandort</span>
{editing ? (
<select className="prop-input" value={form.lagerstandortId} onChange={(e) => setForm(f => ({ ...f, lagerstandortId: e.target.value }))}>
<option value=""> Kein Standort </option>
{activeLagerstandorte.map((l) => (
<option key={l.$id} value={l.$id}>{l.name}</option>
))}
</select>
) : (
<span className="prop-value">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</span>
)}
</div>
<div className="prop-row">
<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>
{/* Zuständig */}
<div className="space-y-1.5">
<Label>Zuständig</Label>
{editing ? (
<Select value={form.zustaendig} onValueChange={(v) => setForm(f => ({ ...f, 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 === userName ? ' (Ich)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm">{asset.zustaendig || ''}</p>
)}
</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 von: <strong>{asset.createdBy || ''}</strong></span>
<span>Zuletzt bearbeitet von: <strong>{asset.lastEditedBy || ''}</strong></span>
<span>Erstellt von: <strong className="text-foreground">{asset.createdBy || ''}</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>
</div>
</div>
</CardFooter>
</Card>
<div className="asset-log-card">
<h2>Änderungsprotokoll</h2>
<div className="log-console">
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen</div>}
{!loadingLogs && logs.length === 0 && (
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
)}
{logs.map((log) => {
const ts = formatTimestamp(log.$createdAt);
const actionClass = log.action === 'erstellt' ? 'log-created'
: log.action === 'status_geaendert' ? 'log-status'
: 'log-edit';
{/* Audit log card */}
<Card>
<CardHeader>
<CardTitle>Änderungsprotokoll</CardTitle>
</CardHeader>
<CardContent>
<div className="log-console">
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen</div>}
{!loadingLogs && logs.length === 0 && (
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
)}
{logs.map((log) => {
const ts = formatTimestamp(log.$createdAt);
const actionClass = log.action === 'erstellt' ? 'log-created'
: log.action === 'status_geaendert' ? 'log-status'
: 'log-edit';
return (
<div key={log.$id} className={`log-entry ${actionClass}`}>
<span className="log-time">[{ts}]</span>
<span className="log-user">{log.userName}</span>
<span className="log-action">{log.action.toUpperCase()}</span>
{log.details && <span className="log-details">{log.details}</span>}
</div>
);
})}
</div>
</div>
return (
<div key={log.$id} className={`log-entry ${actionClass}`}>
<span className="log-time">[{ts}]</span>
<span className="log-user">{log.userName}</span>
<span className="log-action">{log.action.toUpperCase()}</span>
{log.details && <span className="log-details">{log.details}</span>}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
function PropertyRow({ label, value, editing, onChange, mono, textarea }) {
function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
return (
<div className="prop-row">
<span className="prop-label">{label}</span>
<div className={`space-y-1.5 ${className}`}>
<Label>{label}</Label>
{editing ? (
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>
);

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 }) {
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 (
<th className="col-filter-th" ref={ref}>
<button className={`col-filter-btn ${active ? 'active' : ''} ${summary ? 'has-filter' : ''}`} onClick={active ? onClose : onOpen}>
<span className="col-filter-label">{label}</span>
{summary && <span className="col-filter-summary">{summary}</span>}
<span className={`col-filter-arrow ${active ? 'open' : ''}`}>&#9662;</span>
</button>
{active && (
<div className="col-filter-popup">
<th className="h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground">
<Popover open={active} onOpenChange={(nextOpen) => (nextOpen ? onOpen() : onClose())}>
<PopoverTrigger className="inline-flex items-center gap-1.5 text-sm font-medium cursor-pointer transition-colors hover:text-foreground/70">
<span>{label}</span>
{summary && (
<span className="text-xs font-normal text-amber-600 dark:text-amber-400 truncate max-w-20">
{summary}
</span>
)}
<ChevronDown
className={`h-3 w-3 shrink-0 transition-transform duration-200 ${active ? 'rotate-180' : ''}`}
/>
</PopoverTrigger>
<PopoverContent align="start" className="w-56">
{children}
</div>
)}
</PopoverContent>
</Popover>
</th>
);
}
export function TextFilter({ value, onChange, placeholder }) {
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
return (
<input
ref={inputRef}
className="col-filter-input"
<Input
autoFocus
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
@@ -48,9 +39,13 @@ export function TextFilter({ value, onChange, placeholder }) {
export function SelectFilter({ value, onChange, options }) {
return (
<div className="col-filter-options">
<div className="flex flex-col gap-0.5">
<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('')}
>
Alle
@@ -58,7 +53,11 @@ export function SelectFilter({ value, onChange, options }) {
{options.map((opt) => (
<button
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)}
>
{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 }) {
let subject = '';
let text = artikel.kommentar;
@@ -9,14 +19,32 @@ export default function CommentPopup({ artikel, onClose }) {
}
return (
<>
<div className="comment-overlay" onClick={onClose} />
<div className="comment-popup">
<h3>Kommentar zu {artikel.erlNummer}</h3>
{subject && <div className="subject">{subject}</div>}
<div className="text">{text || '(Kein weiterer Kommentar)'}</div>
<button className="close-btn" onClick={onClose}>Schließen</button>
</div>
</>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
<DialogDescription className="sr-only">
Kommentardetails anzeigen
</DialogDescription>
</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 { useAuth } from '../context/AuthContext';
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 }) {
const { isAdmin, isFilialleiter } = useAuth();
@@ -16,16 +24,27 @@ export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort,
return (
<>
<div className="dashboard">
<StatCard color="red" count={counts.offen} label="Offen" />
<StatCard color="yellow" count={counts.bearbeitung} label="In Bearbeitung" />
<StatCard color="gray" count={counts.entsorgt} label="Entsorgt" />
<StatCard color="blue" count={counts.overdue} label="Überfällig (>7 Tage)" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
{STAT_CARDS.map(({ key, color, label }) => (
<Card key={key} className="py-0" style={{ borderTop: `3px solid ${color}` }}>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className="text-sm text-muted-foreground mt-1">{label}</p>
</CardContent>
</Card>
))}
{(isAdmin || isFilialleiter) && (
<div className="stat-card" style={{ borderColor: '#F57C00', cursor: 'pointer' }} onClick={() => setShowManager(true)}>
<div className="stat-number" style={{ fontSize: '24px' }}>{lagerstandorte.length}</div>
<div className="stat-label">Lagerstandorte verwalten</div>
</div>
<Card
className="py-0 cursor-pointer transition-colors hover:bg-muted/50"
style={{ borderTop: '3px solid #F57C00' }}
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>
@@ -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 { 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 = {
erlNummer: '',
@@ -29,6 +41,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
setForm((prev) => ({ ...prev, [name]: value }));
}
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(e) {
e.preventDefault();
@@ -57,64 +73,130 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
}
return (
<div className="form-card">
<div className="form-header">Defekte Ware erfassen</div>
<form className="form-body" onSubmit={handleSubmit}>
<div className="form-group">
<label>ERL-Nummer (Logistik) *</label>
<input name="erlNummer" value={form.erlNummer} onChange={handleChange} placeholder="z.B. ERL-00001" />
</div>
<div className="form-group">
<label>Seriennummer *</label>
<input name="seriennummer" value={form.seriennummer} onChange={handleChange} placeholder="z.B. SN-ABC123456" />
</div>
<div className="form-group">
<label>Artikelnummer</label>
<input name="artikelNr" value={form.artikelNr} onChange={handleChange} placeholder="z.B. ART-20341" />
</div>
<div className="form-group">
<label>Bezeichnung</label>
<input name="bezeichnung" value={form.bezeichnung} onChange={handleChange} placeholder="z.B. Hydraulikpumpe XL" />
</div>
<div className="form-group">
<label>Defektbeschreibung</label>
<textarea name="defekt" value={form.defekt} onChange={handleChange} placeholder="Was genau ist defekt? Wie sieht der Schaden aus?" />
</div>
<div className="form-group">
<label>Lagerstandort</label>
<select name="lagerstandortId" value={form.lagerstandortId} onChange={handleChange}>
<option value="">-- Standort wählen --</option>
{(lagerstandorte || []).map((ls) => (
<option key={ls.$id} value={ls.$id}>{ls.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Zuständig *</label>
<select name="zustaendig" value={form.zustaendig} onChange={handleChange}>
<option value="">-- Mitarbeiter wählen --</option>
{(colleagues || []).map((c) => (
<option key={c.userId} value={c.userName}>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Priorität *</label>
<select name="prio" value={form.prio} onChange={handleChange}>
<option value="niedrig">Niedrig</option>
<option value="mittel">Mittel</option>
<option value="hoch">Hoch</option>
<option value="kritisch">Kritisch</option>
</select>
</div>
<div className="form-group">
<label>Kommentar</label>
<textarea name="kommentar" value={form.kommentar} onChange={handleChange} placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)" />
</div>
<button type="submit" className="btn-submit">Ware erfassen</button>
</form>
</div>
<Card className="border-0 shadow-none">
<CardHeader className="px-0 pt-0">
<CardTitle>Defekte Ware erfassen</CardTitle>
</CardHeader>
<CardContent className="px-0">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>
<Input
id="erlNummer"
name="erlNummer"
value={form.erlNummer}
onChange={handleChange}
placeholder="z.B. ERL-00001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="seriennummer">Seriennummer *</Label>
<Input
id="seriennummer"
name="seriennummer"
value={form.seriennummer}
onChange={handleChange}
placeholder="z.B. SN-ABC123456"
/>
</div>
<div className="space-y-2">
<Label htmlFor="artikelNr">Artikelnummer</Label>
<Input
id="artikelNr"
name="artikelNr"
value={form.artikelNr}
onChange={handleChange}
placeholder="z.B. ART-20341"
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezeichnung">Bezeichnung</Label>
<Input
id="bezeichnung"
name="bezeichnung"
value={form.bezeichnung}
onChange={handleChange}
placeholder="z.B. Hydraulikpumpe XL"
/>
</div>
<div className="space-y-2">
<Label htmlFor="defekt">Defektbeschreibung</Label>
<Textarea
id="defekt"
name="defekt"
value={form.defekt}
onChange={handleChange}
placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"
/>
</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 CommentPopup from './CommentPopup';
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
import { Card } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
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 NEXT_LABEL = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
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 = [
{ value: 'prio', label: 'Priorität' },
@@ -23,6 +33,12 @@ const STATUS_OPTIONS = [
{ 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) {
if (!asset.lagerstandortId) return '';
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 standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
return (
<div className="table-card">
<div className="table-toolbar">
<span className="table-result-count">{filtered.length} Assets</span>
<button className="btn-print-small" onClick={handlePrint} title="Drucken">Drucken</button>
<Card className="py-0 gap-0">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="text-sm font-medium text-muted-foreground">
{filtered.length} Assets
</span>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-3.5 w-3.5" />
Drucken
</Button>
</div>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<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..." />
</ColumnFilter>
<Table>
<TableHeader>
<TableRow>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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..." />
</ColumnFilter>
<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} />
</ColumnFilter>
<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} />
</ColumnFilter>
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
</ColumnFilter>
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
</ColumnFilter>
<ColumnFilter label="Suche nach" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
</ColumnFilter>
<ColumnFilter label="Sortierung" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
</ColumnFilter>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{filtered.map((a) => {
const days = getDaysOld(a.$createdAt);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
return (
<tr key={a.$id} className={overdue ? 'overdue' : ''}>
<td>
<span className={`prio-badge prio-${a.prio}`} />
<strong style={{ color: '#1565C0' }}>{a.erlNummer || ''}</strong>
</td>
<td>
<strong>{a.artikelNr}</strong><br />
<span style={{ fontSize: '12px', color: '#555' }}>{a.bezeichnung}</span>
</td>
<td style={{ fontSize: '12px', fontFamily: 'monospace' }}>{a.seriennummer || ''}</td>
<td style={{ maxWidth: '180px', fontSize: '12px' }}>{a.defekt}</td>
<td style={{ fontSize: '12px' }}>{resolveStandortName(a, lagerstandorte || [])}</td>
<td>
<span className={`badge badge-${STATUS_MAP[a.status]}`}>{STATUS_LABEL[a.status]}</span>
</td>
<td style={{ fontSize: '12px' }}>
{ageText}
{overdue && <><br /><span className="age-warn">Überfällig!</span></>}
</td>
<td>
<button className="btn-action btn-status" onClick={() => handleStatusChange(a.$id)}>
<TableBody>
{filtered.map((a) => {
const days = getDaysOld(a.$createdAt);
const overdue = isOverdue(a);
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
return (
<TableRow
key={a.$id}
className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
>
<TableCell>
<div className="flex items-center gap-2">
<span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
<span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || ''}</span>
</div>
</TableCell>
<TableCell>
<div className="font-medium">{a.artikelNr}</div>
<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]}
</button>
</Button>
{a.kommentar && (
<button className="btn-action btn-info" onClick={() => setCommentAsset(a)}>
<Button variant="outline" size="sm" onClick={() => setCommentAsset(a)}>
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
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{filtered.length === 0 && (
<div className="empty-state">
<div className="emoji">📦</div>
<p>Keine Assets gefunden.</p>
<p style={{ marginTop: '8px' }}>Passe die Filter an oder erfasse ein neues Asset.</p>
</div>
)}
</div>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mb-3 opacity-30" />
<p className="font-medium">Keine Assets gefunden.</p>
<p className="text-sm mt-2">Passe die Filter an oder erfasse ein neues Asset.</p>
</div>
)}
{commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)}
</div>
</Card>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,19 @@
import { useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
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 = {
admin: 'Admin',
@@ -57,41 +70,81 @@ export default function Header({ assets, showToast }) {
const isOnFirmenleiter = loc.pathname === '/firmenleiter';
return (
<header>
<div>
<div className="logo">
Defekt<span>Track</span>
{locationName && <span className="logo-location"> · {locationName}</span>}
</div>
<div className="header-sub">Lager &amp; Logistik · Defekte Ware im Griff by Justin Klein</div>
</div>
<div className="header-buttons">
{user && (
<span className="header-user-info">
{user.name || user.email}
<span className="header-role-badge">{ROLE_LABELS[role] || role}</span>
</span>
<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 className="flex items-center gap-3">
<span className="text-lg font-bold tracking-tight">
Defekt<span className="text-amber-500">Track</span>
</span>
{locationName && (
<>
<Separator orientation="vertical" className="!h-5" />
<span className="text-sm text-muted-foreground">{locationName}</span>
</>
)}
</div>
<nav className="header-nav">
<div className="flex items-center gap-1">
<nav className="flex items-center gap-0.5">
{!isOnTracker && (
<button className="btn-header btn-nav" onClick={() => navigate('/tracker')}>DefektTrack</button>
<Button variant="ghost" size="sm" onClick={() => navigate('/tracker')}>
DefektTrack
</Button>
)}
{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 && (
<button className="btn-header btn-nav" onClick={() => navigate('/filialleiter')}>Filialleiter</button>
<Button variant="ghost" size="sm" onClick={() => navigate('/filialleiter')}>
Filialleiter
</Button>
)}
{(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 && (
<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>
<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>
</header>
);

View File

@@ -1,4 +1,15 @@
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 }) {
const [newName, setNewName] = useState('');
@@ -17,48 +28,61 @@ export default function LagerstandortManager({ lagerstandorte, onAdd, onToggle,
}
return (
<>
<div className="comment-overlay" onClick={onClose} />
<div className="comment-popup" style={{ maxWidth: '550px' }}>
<h3>Lagerstandorte verwalten</h3>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Lagerstandorte verwalten</DialogTitle>
<DialogDescription className="sr-only">
Lagerstandorte hinzufügen, aktivieren oder löschen
</DialogDescription>
</DialogHeader>
<form className="lsm-add-row" onSubmit={handleAdd}>
<input
className="lsm-input"
<form onSubmit={handleAdd} className="flex items-center gap-2">
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
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'}
</button>
</Button>
</form>
<div className="lsm-list">
<Separator />
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{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) => (
<div key={ls.$id} className={`lsm-item ${ls.isActive ? '' : 'inactive'}`}>
<span className="lsm-name">{ls.name}</span>
<div className="lsm-actions">
<button
className={`btn-action ${ls.isActive ? 'btn-status' : 'btn-info'}`}
onClick={() => onToggle(ls.$id)}
>
<div
key={ls.$id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2">
<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'}
</button>
<button className="btn-action btn-delete" onClick={() => onDelete(ls.$id)}>
</Button>
<Button variant="destructive" size="sm" onClick={() => onDelete(ls.$id)}>
Löschen
</button>
</Button>
</div>
</div>
))}
</div>
<button className="close-btn" onClick={onClose}>Schließen</button>
</div>
</>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

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() {
const [toast, setToast] = useState({ message: '', color: '#2E7D32', visible: false });
const timerRef = useRef(null);
const showToast = useCallback((message, color = '#2E7D32') => {
if (timerRef.current) clearTimeout(timerRef.current);
setToast({ message, color, visible: true });
timerRef.current = setTimeout(() => {
setToast((prev) => ({ ...prev, visible: false }));
}, 2800);
const showToast = useCallback((message, color) => {
if (color === '#C62828' || color === 'error') {
toast.error(message);
} else if (color === '#607D8B' || color === 'info') {
toast.info(message);
} else {
toast.success(message);
}
}, []);
return { toast, showToast };
return { showToast };
}

View File

@@ -1,10 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from '@/components/ui/sonner'
import App from './App.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<Toaster richColors position="bottom-right" />
</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
## 1. Login-Gate vor dem Laden der App
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
- [x] ## 1. Login-Gate vor dem Laden der App
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
## 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.
- [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.
## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
- [x] ## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
## 4. Rollen-System
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
- [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.
## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
- [ ] ## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
- [ ] ## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
## 7. PIN-Login-System
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
- [ ] ## 7. PIN-Login-System
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
## 8. Passwort-Hashing
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden.
- [x] ## 8. Passwort-Hashing
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)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
- [x] ## 9. Zugriffskontrolle (Role Based Access Control)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
- [x] ## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
---
# PRIORITY 2 USER EXPERIENCE & DASHBOARDS
## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
- [x] ## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
## 12. Lagerkraft-Startseite
Zeigt primär offene Artikel und operative Aufgaben.
- [x] ## 12. Lagerkraft-Startseite
Zeigt primär offene Artikel und operative Aufgaben (Tracker).
## 13. Service-Startseite
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare.
- [x] ## 13. Service-Startseite
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare (Tracker).
## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
- [x] ## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
- [x] ## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
- [ ] ## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
---
# PRIORITY 3 DEFECT MANAGEMENT CORE
## 17. Defektfall-System
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen.
- [x] ## 17. Defektfall-System
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen (Assets-Collection).
## 18. Status-Workflow
Statussystem für Fälle (Offen → In Bearbeitung → Erledigt → Entsorgt).
- [x] ## 18. Status-Workflow
Statussystem für Fälle (Offen → In Bearbeitung → Entsorgt).
## 19. Prioritätssystem
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
- [x] ## 19. Prioritätssystem
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
## 20. Verantwortlichkeits-System
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden.
- [x] ## 20. Verantwortlichkeits-System
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden (Zuständig-Dropdown aus Appwrite-Benutzern der Filiale).
## 21. Kommentar-System
Interne Kommentare und technische Notizen zu jedem Defektfall.
- [x] ## 21. Kommentar-System
Interne Kommentare und technische Notizen zu jedem Defektfall (Kommentar-Feld, CommentPopup für Anzeige).
## 22. Defekt-Historie
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden.
- [x] ## 22. Defekt-Historie
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden (Audit-Log pro Asset).
---
# PRIORITY 4 SEARCH & FILTERING
## 23. Erweiterte Suche
Suche nach ERL-Nummer, Seriennummer, Artikelnummer oder Beschreibung.
- [x] ## 23. Erweiterte Suche
Suche nach ERL-Nummer, Artikelnummer, Seriennummer, Defektbeschreibung.
## 24. Statusfilter
Filter für offene, in Bearbeitung befindliche, erledigte oder entsorgte Artikel.
- [x] ## 24. Statusfilter
Filter für offene, in Bearbeitung befindliche, entsorgte Artikel.
## 25. Prioritätsfilter
Filter für kritische oder wichtige Fälle.
- [x] ## 25. Prioritätsfilter
Filter/Sortierung nach Priorität (kritisch, hoch, mittel, niedrig).
## 26. Mitarbeiterfilter
Anzeige der Fälle nach zuständigem Mitarbeiter.
- [x] ## 26. Mitarbeiterfilter
Anzeige der Fälle nach zuständigem Mitarbeiter (Sortierung „Mir zugewiesen“).
---
# PRIORITY 5 STATISTICS & ANALYTICS
## 27. Mitarbeiterstatistiken
Eigene offenen, erledigten und überfälligen Fälle eines Mitarbeiters.
- [x] ## 27. Mitarbeiterstatistiken
Eigene offenen, erledigten und überfälligen Fälle (Filialleiter-Dashboard: Mitarbeiter-Performance mit Erledigungsrate).
## 28. Filialstatistiken
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
- [x] ## 28. Filialstatistiken
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
## 29. Unternehmensstatistiken
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen.
- [x] ## 29. Unternehmensstatistiken
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen (Firmenleiter-Dashboard).
## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
- [ ] ## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
- [ ] ## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
---
# PRIORITY 6 ORGANISATION STRUCTURE
## 32. Filial-System
Unterstützung mehrerer Standorte innerhalb eines Unternehmens.
- [x] ## 32. Filial-System
Unterstützung mehrerer Standorte innerhalb eines Unternehmens (locations-Collection, Admin verwaltet Filialen).
## 33. Standortzuweisung für Benutzer
Benutzer gehören zu einer bestimmten Filiale.
- [x] ## 33. Standortzuweisung für Benutzer
Benutzer gehören zu einer bestimmten Filiale (users_meta.locationId).
## 34. Standortfilter für Daten
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
- [x] ## 34. Standortfilter für Daten
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
---
# PRIORITY 7 SYSTEM FEATURES
## 35. Export-Funktion
Datenexport für Berichte oder Archivierung.
- [x] ## 35. Export-Funktion
Datenexport für Berichte oder Archivierung (JSON-Export im Header).
## 36. Import-Funktion
Import von Datensätzen für Migration oder Synchronisation.
- [ ] ## 36. Import-Funktion
Import von Datensätzen für Migration oder Synchronisation.
## 37. Druckansicht
Optimierte Druckansicht für Berichte oder Listen.
- [x] ## 37. Druckansicht
Optimierte Druckansicht für Berichte oder Listen (Drucken-Button in der Asset-Tabelle).
## 38. Benachrichtigungen
Systemmeldungen bei kritischen oder überfälligen Defektfällen.
- [ ] ## 38. Benachrichtigungen
Systemmeldungen bei kritischen oder überfälligen Defektfällen (Toasts vorhanden, keine gezielten Alerts).
---
# PRIORITY 8 FUTURE FEATURES
## 39. Datei-Uploads
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
- [ ] ## 39. Datei-Uploads
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
## 40. Mobile Optimierung
Optimierte Nutzung für Tablets oder mobile Geräte im Lager.
- [x] ## 40. Mobile Optimierung
Optimierte Nutzung für Tablets oder mobile Geräte im Lager (responsive Layout, Sidebar ausgeblendet auf Mobile, Form unten).
## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen.
- [ ] ## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen.
## 42. Automatische Eskalationen
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
- [ ] ## 42. Automatische Eskalationen
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
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.