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>
<form className="filiale-add-form" onSubmit={handleAddFiliale}>
<input
type="text"
className="filiale-input"
<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
type="text"
className="filiale-input"
<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>
<Button type="submit" disabled={addingFiliale || !newFiliale.name.trim()}>
{addingFiliale ? '...' : 'Hinzufügen'}
</Button>
</form>
<div className="panel-list" style={{ marginTop: 16 }}>
{locations.length === 0 && <p className="panel-empty">Keine Filialen vorhanden</p>}
<Separator className="my-4" />
<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={`filiale-admin-item ${loc.isActive ? '' : 'inactive'}`}>
<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="filiale-edit-row">
<input
type="text"
className="filiale-input"
<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
type="text"
className="filiale-input"
<Input
value={editForm.address}
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
placeholder="Adresse"
className="flex-1"
/>
<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="flex gap-2">
<Button size="sm" onClick={handleSaveEdit}>Speichern</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Abbrechen</Button>
</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'}`}>
<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'}
</span>
</Badge>
</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)}>
<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 className="btn-action btn-delete" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</button>
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</Button>
</div>
</>
)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
<div className="panel-card">
<h2>Benutzer</h2>
<div className="panel-list">
{usersList.length === 0 && <p className="panel-empty">Keine Benutzer vorhanden</p>}
{/* 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="panel-list-item">
<div>
<strong>{u.userName || u.userId}</strong>
<span className="panel-list-sub">{u.role}</span>
<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="panel-list-sub">{loc?.name || ''}</span>
<span className="text-sm text-muted-foreground">{loc?.name || ''}</span>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
<div className="panel-card">
<h2>Lagerstandorte</h2>
<button className="btn-panel-action" onClick={() => setShowLsManager(true)}>
{/* Lagerstandorte */}
<Card>
<CardHeader>
<CardTitle>Lagerstandorte</CardTitle>
</CardHeader>
<CardContent>
<Button className="mb-4 w-full" onClick={() => setShowLsManager(true)}>
Lagerstandorte verwalten
</button>
<div className="panel-list" style={{ marginTop: 12 }}>
</Button>
<div className="space-y-2">
{lagerstandorte.map((l) => (
<div key={l.$id} className="panel-list-item">
<span>{l.name}</span>
<span className={`panel-badge ${l.isActive ? 'active' : 'inactive'}`}>
<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'}
</span>
</Badge>
</div>
))}
</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,54 +168,9 @@ export default function AssetDetail() {
}
}
if (loading) {
return (
<div className="asset-detail-page">
<div className="asset-detail-loading">Lade Asset</div>
</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>
);
}
const days = getDaysOld(asset.$createdAt);
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>
</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>
</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({
function resetForm() {
setEditing(false);
setForm({
erlNummer: asset.erlNummer || '',
seriennummer: asset.seriennummer || '',
artikelNr: asset.artikelNr || '',
@@ -199,82 +181,190 @@ export default function AssetDetail() {
status: asset.status || 'offen',
prio: asset.prio || 'mittel',
kommentar: asset.kommentar || '',
}); }}>Abbrechen</button>
});
}
if (loading) {
return (
<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="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>
);
}
const days = getDaysOld(asset.$createdAt);
const overdue = isOverdue(asset);
return (
<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>
<StatusBadge status={asset.status} />
<PrioBadge prio={asset.prio} />
{overdue && (
<Badge variant="destructive" className="text-xs">
Überfällig ({days} Tage)
</Badge>
)}
</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>
{/* 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 className="prop-input" value={form.lagerstandortId} onChange={(e) => setForm(f => ({ ...f, lagerstandortId: e.target.value }))}>
<option value=""> Kein Standort </option>
<Select value={form.lagerstandortId} onValueChange={(v) => setForm(f => ({ ...f, lagerstandortId: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Kein Standort" />
</SelectTrigger>
<SelectContent>
{activeLagerstandorte.map((l) => (
<option key={l.$id} value={l.$id}>{l.name}</option>
<SelectItem key={l.$id} value={l.$id}>{l.name}</SelectItem>
))}
</select>
</SelectContent>
</Select>
) : (
<span className="prop-value">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</span>
<p className="text-sm">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || ''}</p>
)}
</div>
<div className="prop-row">
<span className="prop-label">Zuständig</span>
{/* Zuständig */}
<div className="space-y-1.5">
<Label>Zuständig</Label>
{editing ? (
<select className="prop-input" value={form.zustaendig} onChange={(e) => setForm(f => ({ ...f, zustaendig: e.target.value }))}>
<option value=""> Mitarbeiter wählen </option>
<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) => (
<option key={c.userId} value={c.userName}>
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
) : (
<span className="prop-value">{asset.zustaendig || ''}</span>
<p className="text-sm">{asset.zustaendig || ''}</p>
)}
</div>
<div className="prop-row">
<span className="prop-label">Status</span>
{/* Status */}
<div className="space-y-1.5">
<Label>Status</Label>
{editing ? (
<select className="prop-input" value={form.status} onChange={(e) => setForm(f => ({ ...f, status: e.target.value }))}>
<Select value={form.status} onValueChange={(v) => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
<SelectItem key={s} value={s}>{STATUS_LABEL[s]}</SelectItem>
))}
</select>
</SelectContent>
</Select>
) : (
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
<StatusBadge status={asset.status} />
)}
</div>
<div className="prop-row">
<span className="prop-label">Priorität</span>
{/* Priorität */}
<div className="space-y-1.5">
<Label>Priorität</Label>
{editing ? (
<select className="prop-input" value={form.prio} onChange={(e) => setForm(f => ({ ...f, prio: e.target.value }))}>
<Select value={form.prio} onValueChange={(v) => setForm(f => ({ ...f, prio: v }))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIO_OPTIONS.map((p) => (
<option key={p} value={p}>{PRIO_LABELS[p]}</option>
<SelectItem key={p} value={p}>{PRIO_LABELS[p]}</SelectItem>
))}
</select>
</SelectContent>
</Select>
) : (
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
<PrioBadge prio={asset.prio} />
)}
</div>
<PropertyRow label="Kommentar" value={form.kommentar} field="kommentar" editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea />
</div>
<div className="asset-info-footer">
<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>
{/* 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 && (
@@ -296,23 +386,24 @@ export default function AssetDetail() {
);
})}
</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">
{children}
</div>
<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}
</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>
<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" />
<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="form-group">
<label>Seriennummer *</label>
<input name="seriennummer" value={form.seriennummer} onChange={handleChange} placeholder="z.B. SN-ABC123456" />
<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="form-group">
<label>Artikelnummer</label>
<input name="artikelNr" value={form.artikelNr} onChange={handleChange} placeholder="z.B. ART-20341" />
<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="form-group">
<label>Bezeichnung</label>
<input name="bezeichnung" value={form.bezeichnung} onChange={handleChange} placeholder="z.B. Hydraulikpumpe XL" />
<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="form-group">
<label>Defektbeschreibung</label>
<textarea name="defekt" value={form.defekt} onChange={handleChange} placeholder="Was genau ist defekt? Wie sieht der Schaden aus?" />
<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="form-group">
<label>Lagerstandort</label>
<select name="lagerstandortId" value={form.lagerstandortId} onChange={handleChange}>
<option value="">-- Standort wählen --</option>
<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) => (
<option key={ls.$id} value={ls.$id}>{ls.name}</option>
<SelectItem key={ls.$id} value={ls.$id}>
{ls.name}
</SelectItem>
))}
</select>
</SelectContent>
</Select>
</div>
<div className="form-group">
<label>Zuständig *</label>
<select name="zustaendig" value={form.zustaendig} onChange={handleChange}>
<option value="">-- Mitarbeiter wählen --</option>
<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) => (
<option key={c.userId} value={c.userName}>
<SelectItem key={c.userId} value={c.userName}>
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</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 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="form-group">
<label>Kommentar</label>
<textarea name="kommentar" value={form.kommentar} onChange={handleChange} placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)" />
<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="btn-submit">Ware erfassen</button>
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700">
Ware erfassen
</Button>
</form>
</div>
</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,20 +176,23 @@ 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>
<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>
@@ -198,70 +217,89 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
<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}>
<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>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<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 (
<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' }}>
<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 && <><br /><span className="age-warn">Überfällig!</span></>}
</td>
<td>
<button className="btn-action btn-status" onClick={() => handleStatusChange(a.$id)}>
{NEXT_LABEL[a.status]}
</button>
{a.kommentar && (
<button className="btn-action btn-info" onClick={() => setCommentAsset(a)}>
Info
</button>
{overdue && (
<div className="text-amber-600 dark:text-amber-400 font-medium mt-0.5">Überfällig!</div>
)}
<button className="btn-action btn-edit-link" onClick={() => navigate(`/asset/${a.$id}`)}>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Button variant="secondary" size="sm" onClick={() => handleStatusChange(a.$id)}>
{NEXT_LABEL[a.status]}
</Button>
{a.kommentar && (
<Button variant="outline" size="sm" onClick={() => setCommentAsset(a)}>
Info
</Button>
)}
<Button variant="default" size="sm" onClick={() => navigate(`/asset/${a.$id}`)}>
Bearbeiten
</button>
</td>
</tr>
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</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 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>
)}
</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,8 +55,23 @@ export default function DefektTrackApp() {
}, [assets, changeStatus, addLog, user, userName]);
return (
<>
<div className="flex h-screen flex-col overflow-hidden">
<Header assets={assets} showToast={showToast} />
<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}
@@ -65,8 +79,9 @@ export default function DefektTrackApp() {
onToggleLagerstandort={toggleLagerstandort}
onDeleteLagerstandort={deleteLagerstandort}
/>
<div className="main">
<DefektForm onAdd={handleAdd} showToast={showToast} lagerstandorte={activeLagerstandorte} colleagues={colleagues} />
</div>
<div className="px-4 pb-6">
<DefektTable
assets={assets}
onChangeStatus={handleStatusChange}
@@ -74,7 +89,18 @@ export default function DefektTrackApp() {
lagerstandorte={lagerstandorte}
/>
</div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
{/* Mobile: form below table */}
<div className="p-4 md:hidden">
<DefektForm
onAdd={handleAdd}
showToast={showToast}
lagerstandorte={activeLagerstandorte}
colleagues={colleagues}
/>
</div>
</main>
</div>
</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}`}>
<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}
</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}`}>
</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}
</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>
</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)}%` }}
/>
</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>
<span className="comparison-value">{avgAllFilialen}</span>
<Card className="mb-6">
<CardHeader>
<CardTitle>Filialvergleich</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="w-32 shrink-0 text-sm font-medium">Meine Filiale</span>
<Progress value={Math.round((ownTotal / comparisonMax) * 100)} className="flex-1" />
<span className="w-12 text-right text-sm font-semibold tabular-nums">{ownTotal}</span>
</div>
<div className="flex items-center gap-4">
<span className="w-32 shrink-0 text-sm font-medium"> Durchschnitt</span>
<Progress value={Math.round((avgAllFilialen / comparisonMax) * 100)} className="flex-1" />
<span className="w-12 text-right text-sm font-semibold tabular-nums">{avgAllFilialen}</span>
</div>
</div>
</CardContent>
</Card>
<div className="panel-card" style={{ marginTop: 24 }}>
<h2>Mitarbeiter-Performance</h2>
<Card>
<CardHeader>
<CardTitle>Mitarbeiter-Performance</CardTitle>
</CardHeader>
<CardContent>
{employeeStats.length === 0 ? (
<p className="panel-empty">Keine Mitarbeiter gefunden</p>
<p className="py-4 text-center text-sm text-muted-foreground">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>
<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>
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
</>
);
}

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { 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>
{/* 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 className="panel-stat-card">
<div className="panel-stat-number">{allUsers.length}</div>
<div className="panel-stat-label">Mitarbeiter gesamt</div>
<div>
<p className="text-2xl font-bold">{locations.length}</p>
<p className="text-xs text-muted-foreground">Filialen</p>
</div>
<div className="panel-stat-card">
<div className="panel-stat-number">{totalAssets}</div>
<div className="panel-stat-label">Assets gesamt</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 className="panel-stat-card">
<div className="panel-stat-number">
<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>
<div className="panel-stat-label">Erledigungsrate</div>
</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>
{/* 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>
<div className="panel-stat-card small">
<div className="panel-stat-number" style={{ color: '#F9A825' }}>{totalInProgress}</div>
<div className="panel-stat-label">In Bearbeitung</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>
<div className="panel-stat-card small">
<div className="panel-stat-number" style={{ color: '#43A047' }}>{totalResolved}</div>
<div className="panel-stat-label">Erledigt</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>
{/* Filialen section */}
<Card>
<CardHeader>
<CardTitle>Alle Filialen</CardTitle>
</CardHeader>
<CardContent>
{filialeStats.length === 0 ? (
<p className="panel-empty">Keine Filialen vorhanden</p>
<p className="text-sm text-muted-foreground">Keine Filialen vorhanden</p>
) : (
<div className="filiale-grid">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{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'}`}>
<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'}
</span>
</Badge>
</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>
{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="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 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>
</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>
<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,16 +40,22 @@ 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
<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)}
@@ -53,9 +64,10 @@ export default function Login() {
autoFocus
/>
</div>
<div className="form-group">
<label>Passwort</label>
<input
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -63,12 +75,19 @@ export default function Login() {
autoComplete="current-password"
/>
</div>
{error && <div className="login-error">{error}</div>}
<button type="submit" className="btn-submit" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
{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 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>,
)

141
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
- [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)
- [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)
- [x] ## 3. Benutzerverwaltung (Admin)
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
## 4. Rollen-System
- [x] ## 4. Rollen-System
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
## 5. Startpasswort-System
- [ ] ## 5. Startpasswort-System
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
## 6. Passwort-Änderungspflicht
- [ ] ## 6. Passwort-Änderungspflicht
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
## 7. PIN-Login-System
- [ ] ## 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)
- [x] ## 9. Zugriffskontrolle (Role Based Access Control)
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
## 10. Audit-Log
- [x] ## 10. Audit-Log
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
---
# PRIORITY 2 USER EXPERIENCE & DASHBOARDS
## 11. Rollenbasierte Startseiten
- [x] ## 11. Rollenbasierte Startseiten
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
## 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
- [x] ## 14. Filialleiter-Dashboard
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
## 15. Firmenleiter-Dashboard
- [x] ## 15. Firmenleiter-Dashboard
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
## 16. Automatische Filter je Rolle
- [ ] ## 16. Automatische Filter je Rolle
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
---
# 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
- [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
- [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
- [ ] ## 30. Bearbeitungszeit-Analyse
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
## 31. Häufigste Defekte
- [ ] ## 31. Häufigste Defekte
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
---
# 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
- [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
- [ ] ## 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
- [ ] ## 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
- [ ] ## 41. API-Schnittstellen
Möglichkeit zur Integration mit anderen Systemen.
## 42. Automatische Eskalationen
- [ ] ## 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.