31 von 45 = ca. 69 %
31 punkter der todo liste abgeabeitet
This commit is contained in:
80
package-lock.json
generated
80
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
1072
src/App.css
1072
src/App.css
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { databases, DATABASE_ID } from '../lib/appwrite';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import Header from './Header';
|
||||
import Toast from './Toast';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import LagerstandortManager from './LagerstandortManager';
|
||||
import { useLagerstandorte } from '../hooks/useLagerstandorte';
|
||||
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { user, userMeta } = useAuth();
|
||||
const { toast, showToast } = useToast();
|
||||
const { showToast } = useToast();
|
||||
const locationId = userMeta?.locationId || '';
|
||||
const { lagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
|
||||
|
||||
@@ -115,141 +119,157 @@ export default function AdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const statItems = [
|
||||
{ label: 'Benutzer', value: stats.users },
|
||||
{ label: 'Filialen', value: stats.locations },
|
||||
{ label: 'Assets gesamt', value: stats.assets },
|
||||
{ label: 'Lagerstandorte', value: stats.lagerstandorte },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="panel-page">
|
||||
<div className="panel-title-bar">
|
||||
<h1>Admin Panel</h1>
|
||||
<p>System-Übersicht und Verwaltung</p>
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Admin Panel</h1>
|
||||
<p className="mt-1 text-muted-foreground">System-Übersicht und Verwaltung</p>
|
||||
</div>
|
||||
|
||||
<div className="panel-stats">
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{stats.users}</div>
|
||||
<div className="panel-stat-label">Benutzer</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{stats.locations}</div>
|
||||
<div className="panel-stat-label">Filialen</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{stats.assets}</div>
|
||||
<div className="panel-stat-label">Assets gesamt</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{stats.lagerstandorte}</div>
|
||||
<div className="panel-stat-label">Lagerstandorte</div>
|
||||
</div>
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{statItems.map((item) => (
|
||||
<Card key={item.label}>
|
||||
<CardContent className="pt-2">
|
||||
<div className="text-3xl font-bold">{item.value}</div>
|
||||
<p className="text-sm text-muted-foreground">{item.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="panel-grid">
|
||||
<div className="panel-card">
|
||||
<h2>Filialen verwalten</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Filialen */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Filialen verwalten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleAddFiliale}>
|
||||
<Input
|
||||
value={newFiliale.name}
|
||||
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Filialname (z.B. Kaiserslautern)"
|
||||
/>
|
||||
<Input
|
||||
value={newFiliale.address}
|
||||
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="Adresse (optional)"
|
||||
/>
|
||||
<Button type="submit" disabled={addingFiliale || !newFiliale.name.trim()}>
|
||||
{addingFiliale ? '...' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form className="filiale-add-form" onSubmit={handleAddFiliale}>
|
||||
<input
|
||||
type="text"
|
||||
className="filiale-input"
|
||||
value={newFiliale.name}
|
||||
onChange={(e) => setNewFiliale((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Filialname (z.B. Kaiserslautern)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="filiale-input"
|
||||
value={newFiliale.address}
|
||||
onChange={(e) => setNewFiliale((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="Adresse (optional)"
|
||||
/>
|
||||
<button type="submit" className="btn-panel-action" disabled={addingFiliale || !newFiliale.name.trim()}>
|
||||
{addingFiliale ? '...' : 'Filiale hinzufügen'}
|
||||
</button>
|
||||
</form>
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="panel-list" style={{ marginTop: 16 }}>
|
||||
{locations.length === 0 && <p className="panel-empty">Keine Filialen vorhanden</p>}
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.$id} className={`filiale-admin-item ${loc.isActive ? '' : 'inactive'}`}>
|
||||
{editingId === loc.$id ? (
|
||||
<div className="filiale-edit-row">
|
||||
<input
|
||||
type="text"
|
||||
className="filiale-input"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Filialname"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="filiale-input"
|
||||
value={editForm.address}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="Adresse"
|
||||
/>
|
||||
<div className="filiale-edit-btns">
|
||||
<button className="btn-action btn-status" onClick={handleSaveEdit}>Speichern</button>
|
||||
<button className="btn-action btn-info" onClick={() => setEditingId(null)}>Abbrechen</button>
|
||||
<div className="space-y-3">
|
||||
{locations.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Keine Filialen vorhanden</p>
|
||||
)}
|
||||
{locations.map((loc) => (
|
||||
<div
|
||||
key={loc.$id}
|
||||
className={`flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between ${!loc.isActive ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{editingId === loc.$id ? (
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Filialname"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={editForm.address}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="Adresse"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveEdit}>Speichern</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="filiale-admin-info">
|
||||
<strong>{loc.name}</strong>
|
||||
{loc.address && <span className="panel-list-sub">{loc.address}</span>}
|
||||
<span className={`panel-badge ${loc.isActive ? 'active' : 'inactive'}`}>
|
||||
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="filiale-admin-actions">
|
||||
<button className="btn-action btn-info" onClick={() => startEdit(loc)}>Bearbeiten</button>
|
||||
<button className="btn-action btn-status" onClick={() => handleToggleFiliale(loc.$id)}>
|
||||
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button className="btn-action btn-delete" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-card">
|
||||
<h2>Benutzer</h2>
|
||||
<div className="panel-list">
|
||||
{usersList.length === 0 && <p className="panel-empty">Keine Benutzer vorhanden</p>}
|
||||
{usersList.map((u) => {
|
||||
const loc = locations.find((l) => l.$id === u.locationId);
|
||||
return (
|
||||
<div key={u.$id} className="panel-list-item">
|
||||
<div>
|
||||
<strong>{u.userName || u.userId}</strong>
|
||||
<span className="panel-list-sub">{u.role}</span>
|
||||
</div>
|
||||
<span className="panel-list-sub">{loc?.name || '–'}</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{loc.name}</span>
|
||||
{loc.address && <span className="text-sm text-muted-foreground">{loc.address}</span>}
|
||||
<Badge variant={loc.isActive ? 'default' : 'outline'}>
|
||||
{loc.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => startEdit(loc)}>Bearbeiten</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => handleToggleFiliale(loc.$id)}>
|
||||
{loc.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeleteFiliale(loc.$id)}>Löschen</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="panel-card">
|
||||
<h2>Lagerstandorte</h2>
|
||||
<button className="btn-panel-action" onClick={() => setShowLsManager(true)}>
|
||||
Lagerstandorte verwalten
|
||||
</button>
|
||||
<div className="panel-list" style={{ marginTop: 12 }}>
|
||||
{lagerstandorte.map((l) => (
|
||||
<div key={l.$id} className="panel-list-item">
|
||||
<span>{l.name}</span>
|
||||
<span className={`panel-badge ${l.isActive ? 'active' : 'inactive'}`}>
|
||||
{l.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Benutzer */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Benutzer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{usersList.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Keine Benutzer vorhanden</p>
|
||||
)}
|
||||
{usersList.map((u) => {
|
||||
const loc = locations.find((l) => l.$id === u.locationId);
|
||||
return (
|
||||
<div key={u.$id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{u.userName || u.userId}</span>
|
||||
<Badge variant="secondary">{u.role}</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{loc?.name || '–'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lagerstandorte */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lagerstandorte</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="mb-4 w-full" onClick={() => setShowLsManager(true)}>
|
||||
Lagerstandorte verwalten
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
{lagerstandorte.map((l) => (
|
||||
<div key={l.$id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm">{l.name}</span>
|
||||
<Badge variant={l.isActive ? 'default' : 'outline'}>
|
||||
{l.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +282,6 @@ export default function AdminPanel() {
|
||||
onClose={() => setShowLsManager(false)}
|
||||
/>
|
||||
)}
|
||||
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { databases, DATABASE_ID } from '../lib/appwrite';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useAuditLog } from '../hooks/useAuditLog';
|
||||
import { useLagerstandorte } from '../hooks/useLagerstandorte';
|
||||
import { useColleagues } from '../hooks/useColleagues';
|
||||
import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useAuditLog } from '@/hooks/useAuditLog';
|
||||
import { useLagerstandorte } from '@/hooks/useLagerstandorte';
|
||||
import { useColleagues } from '@/hooks/useColleagues';
|
||||
import { getDaysOld, isOverdue } from '@/hooks/useAssets';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowLeft, Pencil, Save, X } from 'lucide-react';
|
||||
|
||||
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
|
||||
const STATUS_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
|
||||
const PRIO_LABELS = { kritisch: 'Kritisch', hoch: 'Hoch', mittel: 'Mittel', niedrig: 'Niedrig' };
|
||||
const PRIO_OPTIONS = ['kritisch', 'hoch', 'mittel', 'niedrig'];
|
||||
const STATUS_OPTIONS = ['offen', 'in_bearbeitung', 'entsorgt'];
|
||||
@@ -20,6 +34,19 @@ function formatTimestamp(ts) {
|
||||
+ ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (status === 'offen') return <Badge variant="destructive">{STATUS_LABEL[status]}</Badge>;
|
||||
if (status === 'in_bearbeitung') return <Badge className="border-amber-300 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">{STATUS_LABEL[status]}</Badge>;
|
||||
return <Badge variant="secondary">{STATUS_LABEL[status]}</Badge>;
|
||||
}
|
||||
|
||||
function PrioBadge({ prio }) {
|
||||
if (prio === 'kritisch') return <Badge variant="destructive">{PRIO_LABELS[prio]}</Badge>;
|
||||
if (prio === 'hoch') return <Badge className="border-orange-300 bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">{PRIO_LABELS[prio]}</Badge>;
|
||||
if (prio === 'mittel') return <Badge className="border-yellow-300 bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">{PRIO_LABELS[prio]}</Badge>;
|
||||
return <Badge className="border-green-300 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">{PRIO_LABELS[prio]}</Badge>;
|
||||
}
|
||||
|
||||
export default function AssetDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -141,22 +168,47 @@ export default function AssetDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditing(false);
|
||||
setForm({
|
||||
erlNummer: asset.erlNummer || '',
|
||||
seriennummer: asset.seriennummer || '',
|
||||
artikelNr: asset.artikelNr || '',
|
||||
bezeichnung: asset.bezeichnung || '',
|
||||
defekt: asset.defekt || '',
|
||||
lagerstandortId: asset.lagerstandortId || '',
|
||||
zustaendig: asset.zustaendig || '',
|
||||
status: asset.status || 'offen',
|
||||
prio: asset.prio || 'mittel',
|
||||
kommentar: asset.kommentar || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-loading">Lade Asset…</div>
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center gap-4 p-6 pt-24">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-not-found">
|
||||
<h2>Asset nicht gefunden</h2>
|
||||
<p>Das Asset mit der ID <code>{id}</code> existiert nicht.</p>
|
||||
<button className="btn-back" onClick={() => navigate('/tracker')}>Zurück zur Übersicht</button>
|
||||
</div>
|
||||
<div className="mx-auto max-w-4xl p-6 pt-12">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<h2 className="text-lg font-semibold">Asset nicht gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Das Asset mit der ID <code className="rounded bg-muted px-1 py-0.5 text-xs">{id}</code> existiert nicht.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => navigate('/tracker')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,154 +217,193 @@ export default function AssetDetail() {
|
||||
const overdue = isOverdue(asset);
|
||||
|
||||
return (
|
||||
<div className="asset-detail-page">
|
||||
<div className="asset-detail-header">
|
||||
<button className="btn-back" onClick={() => navigate('/tracker')}>← Zurück</button>
|
||||
<h1>
|
||||
Asset: <span style={{ color: '#1565C0' }}>{asset.erlNummer || '–'}</span>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
{/* Back button */}
|
||||
<Button variant="outline" className="mb-4" onClick={() => navigate('/tracker')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
{/* Header area */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Asset: <span className="text-blue-600 dark:text-blue-400">{asset.erlNummer || '–'}</span>
|
||||
</h1>
|
||||
<div className="asset-detail-meta">
|
||||
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
|
||||
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
|
||||
{overdue && <span className="age-warn">Überfällig ({days} Tage)</span>}
|
||||
</div>
|
||||
<StatusBadge status={asset.status} />
|
||||
<PrioBadge prio={asset.prio} />
|
||||
{overdue && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Überfällig ({days} Tage)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-detail-card">
|
||||
<div className="asset-detail-card-header">
|
||||
<h2>Eigenschaften</h2>
|
||||
{!editing ? (
|
||||
<button className="btn-edit" onClick={() => setEditing(true)}>Bearbeiten</button>
|
||||
) : (
|
||||
<div className="edit-actions">
|
||||
<button className="btn-save" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn-cancel" onClick={() => { setEditing(false); setForm({
|
||||
erlNummer: asset.erlNummer || '',
|
||||
seriennummer: asset.seriennummer || '',
|
||||
artikelNr: asset.artikelNr || '',
|
||||
bezeichnung: asset.bezeichnung || '',
|
||||
defekt: asset.defekt || '',
|
||||
lagerstandortId: asset.lagerstandortId || '',
|
||||
zustaendig: asset.zustaendig || '',
|
||||
status: asset.status || 'offen',
|
||||
prio: asset.prio || 'mittel',
|
||||
kommentar: asset.kommentar || '',
|
||||
}); }}>Abbrechen</button>
|
||||
{/* Properties card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>Eigenschaften</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={resetForm}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<PropertyField label="ERL-Nr." value={form.erlNummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
|
||||
<PropertyField label="Artikelnr." value={form.artikelNr} editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
|
||||
<PropertyField label="Bezeichnung" value={form.bezeichnung} editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
|
||||
<PropertyField label="Seriennummer" value={form.seriennummer} editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
|
||||
<PropertyField label="Defekt" value={form.defekt} editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea className="sm:col-span-2" />
|
||||
|
||||
{/* Lagerstandort */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Lagerstandort</Label>
|
||||
{editing ? (
|
||||
<Select value={form.lagerstandortId} onValueChange={(v) => setForm(f => ({ ...f, lagerstandortId: v }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Kein Standort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeLagerstandorte.map((l) => (
|
||||
<SelectItem key={l.$id} value={l.$id}>{l.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || '–'}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-props-grid">
|
||||
<PropertyRow label="ERL-Nr." value={form.erlNummer} field="erlNummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, erlNummer: v }))} />
|
||||
<PropertyRow label="Artikelnr." value={form.artikelNr} field="artikelNr" editing={editing} onChange={(v) => setForm(f => ({ ...f, artikelNr: v }))} />
|
||||
<PropertyRow label="Bezeichnung" value={form.bezeichnung} field="bezeichnung" editing={editing} onChange={(v) => setForm(f => ({ ...f, bezeichnung: v }))} />
|
||||
<PropertyRow label="Seriennummer" value={form.seriennummer} field="seriennummer" editing={editing} onChange={(v) => setForm(f => ({ ...f, seriennummer: v }))} mono />
|
||||
<PropertyRow label="Defekt" value={form.defekt} field="defekt" editing={editing} onChange={(v) => setForm(f => ({ ...f, defekt: v }))} textarea />
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Lagerstandort</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.lagerstandortId} onChange={(e) => setForm(f => ({ ...f, lagerstandortId: e.target.value }))}>
|
||||
<option value="">– Kein Standort –</option>
|
||||
{activeLagerstandorte.map((l) => (
|
||||
<option key={l.$id} value={l.$id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="prop-value">{activeLagerstandorte.find(l => l.$id === asset.lagerstandortId)?.name || '–'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Zuständig</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.zustaendig} onChange={(e) => setForm(f => ({ ...f, zustaendig: e.target.value }))}>
|
||||
<option value="">– Mitarbeiter wählen –</option>
|
||||
{colleagues.map((c) => (
|
||||
<option key={c.userId} value={c.userName}>
|
||||
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="prop-value">{asset.zustaendig || '–'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Status</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.status} onChange={(e) => setForm(f => ({ ...f, status: e.target.value }))}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`badge badge-${STATUS_MAP[asset.status]}`}>{STATUS_LABEL[asset.status]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Priorität</span>
|
||||
{editing ? (
|
||||
<select className="prop-input" value={form.prio} onChange={(e) => setForm(f => ({ ...f, prio: e.target.value }))}>
|
||||
{PRIO_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>{PRIO_LABELS[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`prio-badge-lg prio-${asset.prio}`}>{PRIO_LABELS[asset.prio]}</span>
|
||||
)}
|
||||
</div>
|
||||
<PropertyRow label="Kommentar" value={form.kommentar} field="kommentar" editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea />
|
||||
</div>
|
||||
{/* Zuständig */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Zuständig</Label>
|
||||
{editing ? (
|
||||
<Select value={form.zustaendig} onValueChange={(v) => setForm(f => ({ ...f, zustaendig: v }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Mitarbeiter wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colleagues.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userName}>
|
||||
{c.userName}{c.userName === userName ? ' (Ich)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm">{asset.zustaendig || '–'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-info-footer">
|
||||
{/* Status */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
{editing ? (
|
||||
<Select value={form.status} onValueChange={(v) => setForm(f => ({ ...f, status: v }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s} value={s}>{STATUS_LABEL[s]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<StatusBadge status={asset.status} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorität */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priorität</Label>
|
||||
{editing ? (
|
||||
<Select value={form.prio} onValueChange={(v) => setForm(f => ({ ...f, prio: v }))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIO_OPTIONS.map((p) => (
|
||||
<SelectItem key={p} value={p}>{PRIO_LABELS[p]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<PrioBadge prio={asset.prio} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PropertyField label="Kommentar" value={form.kommentar} editing={editing} onChange={(v) => setForm(f => ({ ...f, kommentar: v }))} textarea className="sm:col-span-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Erstellt am: {formatTimestamp(asset.$createdAt)}</span>
|
||||
<span>Erstellt von: <strong>{asset.createdBy || '–'}</strong></span>
|
||||
<span>Zuletzt bearbeitet von: <strong>{asset.lastEditedBy || '–'}</strong></span>
|
||||
<span>Erstellt von: <strong className="text-foreground">{asset.createdBy || '–'}</strong></span>
|
||||
<span>Zuletzt bearbeitet von: <strong className="text-foreground">{asset.lastEditedBy || '–'}</strong></span>
|
||||
<span>Alter: {days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<div className="asset-log-card">
|
||||
<h2>Änderungsprotokoll</h2>
|
||||
<div className="log-console">
|
||||
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen…</div>}
|
||||
{!loadingLogs && logs.length === 0 && (
|
||||
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
|
||||
)}
|
||||
{logs.map((log) => {
|
||||
const ts = formatTimestamp(log.$createdAt);
|
||||
const actionClass = log.action === 'erstellt' ? 'log-created'
|
||||
: log.action === 'status_geaendert' ? 'log-status'
|
||||
: 'log-edit';
|
||||
{/* Audit log card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Änderungsprotokoll</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="log-console">
|
||||
{loadingLogs && <div className="log-entry log-system">[System] Logs werden geladen…</div>}
|
||||
{!loadingLogs && logs.length === 0 && (
|
||||
<div className="log-entry log-system">[System] Keine Einträge vorhanden.</div>
|
||||
)}
|
||||
{logs.map((log) => {
|
||||
const ts = formatTimestamp(log.$createdAt);
|
||||
const actionClass = log.action === 'erstellt' ? 'log-created'
|
||||
: log.action === 'status_geaendert' ? 'log-status'
|
||||
: 'log-edit';
|
||||
|
||||
return (
|
||||
<div key={log.$id} className={`log-entry ${actionClass}`}>
|
||||
<span className="log-time">[{ts}]</span>
|
||||
<span className="log-user">{log.userName}</span>
|
||||
<span className="log-action">{log.action.toUpperCase()}</span>
|
||||
{log.details && <span className="log-details">{log.details}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div key={log.$id} className={`log-entry ${actionClass}`}>
|
||||
<span className="log-time">[{ts}]</span>
|
||||
<span className="log-user">{log.userName}</span>
|
||||
<span className="log-action">{log.action.toUpperCase()}</span>
|
||||
{log.details && <span className="log-details">{log.details}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyRow({ label, value, editing, onChange, mono, textarea }) {
|
||||
function PropertyField({ label, value, editing, onChange, mono, textarea, className = '' }) {
|
||||
return (
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">{label}</span>
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
<Label>{label}</Label>
|
||||
{editing ? (
|
||||
textarea ? (
|
||||
<textarea className="prop-input" value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
|
||||
<Textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
|
||||
) : (
|
||||
<input className="prop-input" type="text" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
<Input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
) : (
|
||||
<span className={`prop-value${mono ? ' mono' : ''}`}>{value || '–'}</span>
|
||||
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '–'}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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' : ''}`}>▾</span>
|
||||
</button>
|
||||
{active && (
|
||||
<div className="col-filter-popup">
|
||||
<th className="h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground">
|
||||
<Popover open={active} onOpenChange={(nextOpen) => (nextOpen ? onOpen() : onClose())}>
|
||||
<PopoverTrigger className="inline-flex items-center gap-1.5 text-sm font-medium cursor-pointer transition-colors hover:text-foreground/70">
|
||||
<span>{label}</span>
|
||||
{summary && (
|
||||
<span className="text-xs font-normal text-amber-600 dark:text-amber-400 truncate max-w-20">
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 shrink-0 transition-transform duration-200 ${active ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextFilter({ value, onChange, placeholder }) {
|
||||
const inputRef = useRef(null);
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="col-filter-input"
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -48,9 +39,13 @@ export function TextFilter({ value, onChange, placeholder }) {
|
||||
|
||||
export function SelectFilter({ value, onChange, options }) {
|
||||
return (
|
||||
<div className="col-filter-options">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
className={`col-filter-option ${!value ? 'selected' : ''}`}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
|
||||
!value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
Alle
|
||||
@@ -58,7 +53,11 @@ export function SelectFilter({ value, onChange, options }) {
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`col-filter-option ${value === opt.value ? 'selected' : ''}`}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm transition-colors ${
|
||||
value === opt.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function CommentPopup({ artikel, onClose }) {
|
||||
let subject = '';
|
||||
let text = artikel.kommentar;
|
||||
@@ -9,14 +19,32 @@ export default function CommentPopup({ artikel, onClose }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="comment-overlay" onClick={onClose} />
|
||||
<div className="comment-popup">
|
||||
<h3>Kommentar zu {artikel.erlNummer}</h3>
|
||||
{subject && <div className="subject">{subject}</div>}
|
||||
<div className="text">{text || '(Kein weiterer Kommentar)'}</div>
|
||||
<button className="close-btn" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
</>
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kommentar zu {artikel.erlNummer}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Kommentardetails anzeigen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{subject && (
|
||||
<div className="rounded-md bg-amber-100 px-3 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
{subject}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm whitespace-pre-wrap">
|
||||
{text || '(Kein weiterer Kommentar)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
erlNummer: '',
|
||||
@@ -29,6 +41,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
function setField(name, value) {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -57,64 +73,130 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">Defekte Ware erfassen</div>
|
||||
<form className="form-body" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>ERL-Nummer (Logistik) *</label>
|
||||
<input name="erlNummer" value={form.erlNummer} onChange={handleChange} placeholder="z.B. ERL-00001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Seriennummer *</label>
|
||||
<input name="seriennummer" value={form.seriennummer} onChange={handleChange} placeholder="z.B. SN-ABC123456" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Artikelnummer</label>
|
||||
<input name="artikelNr" value={form.artikelNr} onChange={handleChange} placeholder="z.B. ART-20341" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Bezeichnung</label>
|
||||
<input name="bezeichnung" value={form.bezeichnung} onChange={handleChange} placeholder="z.B. Hydraulikpumpe XL" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Defektbeschreibung</label>
|
||||
<textarea name="defekt" value={form.defekt} onChange={handleChange} placeholder="Was genau ist defekt? Wie sieht der Schaden aus?" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Lagerstandort</label>
|
||||
<select name="lagerstandortId" value={form.lagerstandortId} onChange={handleChange}>
|
||||
<option value="">-- Standort wählen --</option>
|
||||
{(lagerstandorte || []).map((ls) => (
|
||||
<option key={ls.$id} value={ls.$id}>{ls.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Zuständig *</label>
|
||||
<select name="zustaendig" value={form.zustaendig} onChange={handleChange}>
|
||||
<option value="">-- Mitarbeiter wählen --</option>
|
||||
{(colleagues || []).map((c) => (
|
||||
<option key={c.userId} value={c.userName}>
|
||||
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Priorität *</label>
|
||||
<select name="prio" value={form.prio} onChange={handleChange}>
|
||||
<option value="niedrig">Niedrig</option>
|
||||
<option value="mittel">Mittel</option>
|
||||
<option value="hoch">Hoch</option>
|
||||
<option value="kritisch">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Kommentar</label>
|
||||
<textarea name="kommentar" value={form.kommentar} onChange={handleChange} placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)" />
|
||||
</div>
|
||||
<button type="submit" className="btn-submit">Ware erfassen</button>
|
||||
</form>
|
||||
</div>
|
||||
<Card className="border-0 shadow-none">
|
||||
<CardHeader className="px-0 pt-0">
|
||||
<CardTitle>Defekte Ware erfassen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>
|
||||
<Input
|
||||
id="erlNummer"
|
||||
name="erlNummer"
|
||||
value={form.erlNummer}
|
||||
onChange={handleChange}
|
||||
placeholder="z.B. ERL-00001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="seriennummer">Seriennummer *</Label>
|
||||
<Input
|
||||
id="seriennummer"
|
||||
name="seriennummer"
|
||||
value={form.seriennummer}
|
||||
onChange={handleChange}
|
||||
placeholder="z.B. SN-ABC123456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="artikelNr">Artikelnummer</Label>
|
||||
<Input
|
||||
id="artikelNr"
|
||||
name="artikelNr"
|
||||
value={form.artikelNr}
|
||||
onChange={handleChange}
|
||||
placeholder="z.B. ART-20341"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bezeichnung">Bezeichnung</Label>
|
||||
<Input
|
||||
id="bezeichnung"
|
||||
name="bezeichnung"
|
||||
value={form.bezeichnung}
|
||||
onChange={handleChange}
|
||||
placeholder="z.B. Hydraulikpumpe XL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defekt">Defektbeschreibung</Label>
|
||||
<Textarea
|
||||
id="defekt"
|
||||
name="defekt"
|
||||
value={form.defekt}
|
||||
onChange={handleChange}
|
||||
placeholder="Was genau ist defekt? Wie sieht der Schaden aus?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Lagerstandort</Label>
|
||||
<Select value={form.lagerstandortId} onValueChange={(v) => setField('lagerstandortId', v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Standort wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(lagerstandorte || []).map((ls) => (
|
||||
<SelectItem key={ls.$id} value={ls.$id}>
|
||||
{ls.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Zuständig *</Label>
|
||||
<Select value={form.zustaendig} onValueChange={(v) => setField('zustaendig', v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Mitarbeiter wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(colleagues || []).map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userName}>
|
||||
{c.userName}{c.userName === ownName ? ' (Ich)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Priorität *</Label>
|
||||
<Select value={form.prio} onValueChange={(v) => setField('prio', v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priorität wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="niedrig">Niedrig</SelectItem>
|
||||
<SelectItem value="mittel">Mittel</SelectItem>
|
||||
<SelectItem value="hoch">Hoch</SelectItem>
|
||||
<SelectItem value="kritisch">Kritisch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kommentar">Kommentar</Label>
|
||||
<Textarea
|
||||
id="kommentar"
|
||||
name="kommentar"
|
||||
value={form.kommentar}
|
||||
onChange={handleChange}
|
||||
placeholder="*E-Mail Betreff* Notizen... (Betreff mit * markieren)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-amber-600 hover:bg-amber-700">
|
||||
Ware erfassen
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import { getDaysOld, isOverdue } from '../hooks/useAssets';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import CommentPopup from './CommentPopup';
|
||||
import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Printer, Package } from 'lucide-react';
|
||||
|
||||
const STATUS_MAP = { offen: 'offen', in_bearbeitung: 'bearbeitung', entsorgt: 'entsorgt' };
|
||||
const STATUS_LABEL = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', entsorgt: 'Entsorgt' };
|
||||
const NEXT_LABEL = { offen: '→ In Bearbeitung', in_bearbeitung: '→ Entsorgen', entsorgt: '→ Neu öffnen' };
|
||||
const PRIO_ORDER = { kritisch: 0, hoch: 1, mittel: 2, niedrig: 3 };
|
||||
const PRIO_COLORS = {
|
||||
kritisch: 'bg-red-600',
|
||||
hoch: 'bg-orange-500',
|
||||
mittel: 'bg-yellow-500',
|
||||
niedrig: 'bg-green-500',
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'prio', label: 'Priorität' },
|
||||
@@ -23,6 +33,12 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'entsorgt', label: 'Entsorgt' },
|
||||
];
|
||||
|
||||
const STATUS_BADGE_CONFIG = {
|
||||
offen: { variant: 'destructive' },
|
||||
in_bearbeitung: { variant: 'default', className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
entsorgt: { variant: 'secondary' },
|
||||
};
|
||||
|
||||
function resolveStandortName(asset, lagerstandorte) {
|
||||
if (!asset.lagerstandortId) return '–';
|
||||
const ls = lagerstandorte.find((l) => l.$id === asset.lagerstandortId);
|
||||
@@ -160,108 +176,130 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
|
||||
}
|
||||
|
||||
const sortLabel = SORT_OPTIONS.find((o) => o.value === filters.sortBy)?.label || '';
|
||||
|
||||
const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name }));
|
||||
|
||||
return (
|
||||
<div className="table-card">
|
||||
<div className="table-toolbar">
|
||||
<span className="table-result-count">{filtered.length} Assets</span>
|
||||
<button className="btn-print-small" onClick={handlePrint} title="Drucken">Drucken</button>
|
||||
<Card className="py-0 gap-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{filtered.length} Assets
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Drucken
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.erlNummer} onChange={(v) => setFilter('erlNummer', v)} placeholder="ERL-Nummer suchen..." />
|
||||
</ColumnFilter>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<ColumnFilter label="ERL-Nr." active={activeFilter === 'erl'} summary={filters.erlNummer || null} onOpen={() => openFilter('erl')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.erlNummer} onChange={(v) => setFilter('erlNummer', v)} placeholder="ERL-Nummer suchen..." />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Artikel" active={activeFilter === 'artikel'} summary={filters.artikel || null} onOpen={() => openFilter('artikel')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.artikel} onChange={(v) => setFilter('artikel', v)} placeholder="Artikelnr. oder Name..." />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Artikel" active={activeFilter === 'artikel'} summary={filters.artikel || null} onOpen={() => openFilter('artikel')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.artikel} onChange={(v) => setFilter('artikel', v)} placeholder="Artikelnr. oder Name..." />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Seriennr." active={activeFilter === 'seriennummer'} summary={filters.seriennummer || null} onOpen={() => openFilter('seriennummer')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.seriennummer} onChange={(v) => setFilter('seriennummer', v)} placeholder="Seriennummer suchen..." />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Seriennr." active={activeFilter === 'seriennummer'} summary={filters.seriennummer || null} onOpen={() => openFilter('seriennummer')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.seriennummer} onChange={(v) => setFilter('seriennummer', v)} placeholder="Seriennummer suchen..." />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Defekt" active={activeFilter === 'defekt'} summary={filters.defekt || null} onOpen={() => openFilter('defekt')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.defekt} onChange={(v) => setFilter('defekt', v)} placeholder="Defekt suchen..." />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Defekt" active={activeFilter === 'defekt'} summary={filters.defekt || null} onOpen={() => openFilter('defekt')} onClose={closeFilter}>
|
||||
<TextFilter value={filters.defekt} onChange={(v) => setFilter('defekt', v)} placeholder="Defekt suchen..." />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Standort" active={activeFilter === 'standort'} summary={filters.standort ? lsMap[filters.standort] : null} onOpen={() => openFilter('standort')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Standort" active={activeFilter === 'standort'} summary={filters.standort ? lsMap[filters.standort] : null} onOpen={() => openFilter('standort')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
|
||||
<ColumnFilter label="Suche nach" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
<ColumnFilter label="Sortierung" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
|
||||
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
|
||||
</ColumnFilter>
|
||||
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((a) => {
|
||||
const days = getDaysOld(a.$createdAt);
|
||||
const overdue = isOverdue(a);
|
||||
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
|
||||
<TableHead>Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
return (
|
||||
<tr key={a.$id} className={overdue ? 'overdue' : ''}>
|
||||
<td>
|
||||
<span className={`prio-badge prio-${a.prio}`} />
|
||||
<strong style={{ color: '#1565C0' }}>{a.erlNummer || '–'}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{a.artikelNr}</strong><br />
|
||||
<span style={{ fontSize: '12px', color: '#555' }}>{a.bezeichnung}</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '12px', fontFamily: 'monospace' }}>{a.seriennummer || '–'}</td>
|
||||
<td style={{ maxWidth: '180px', fontSize: '12px' }}>{a.defekt}</td>
|
||||
<td style={{ fontSize: '12px' }}>{resolveStandortName(a, lagerstandorte || [])}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${STATUS_MAP[a.status]}`}>{STATUS_LABEL[a.status]}</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '12px' }}>
|
||||
{ageText}
|
||||
{overdue && <><br /><span className="age-warn">Überfällig!</span></>}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-action btn-status" onClick={() => handleStatusChange(a.$id)}>
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
const days = getDaysOld(a.$createdAt);
|
||||
const overdue = isOverdue(a);
|
||||
const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`;
|
||||
const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={a.$id}
|
||||
className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || '–'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="font-medium">{a.artikelNr}</div>
|
||||
<div className="text-xs text-muted-foreground">{a.bezeichnung}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="font-mono text-xs">{a.seriennummer || '–'}</TableCell>
|
||||
|
||||
<TableCell className="max-w-[180px] text-xs truncate">{a.defekt}</TableCell>
|
||||
|
||||
<TableCell className="text-xs">{resolveStandortName(a, lagerstandorte || [])}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant={badgeCfg.variant} className={badgeCfg.className}>
|
||||
{STATUS_LABEL[a.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-xs">
|
||||
{ageText}
|
||||
{overdue && (
|
||||
<div className="text-amber-600 dark:text-amber-400 font-medium mt-0.5">Überfällig!</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleStatusChange(a.$id)}>
|
||||
{NEXT_LABEL[a.status]}
|
||||
</button>
|
||||
</Button>
|
||||
{a.kommentar && (
|
||||
<button className="btn-action btn-info" onClick={() => setCommentAsset(a)}>
|
||||
<Button variant="outline" size="sm" onClick={() => setCommentAsset(a)}>
|
||||
Info
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button className="btn-action btn-edit-link" onClick={() => navigate(`/asset/${a.$id}`)}>
|
||||
<Button variant="default" size="sm" onClick={() => navigate(`/asset/${a.$id}`)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="emoji">📦</div>
|
||||
<p>Keine Assets gefunden.</p>
|
||||
<p style={{ marginTop: '8px' }}>Passe die Filter an oder erfasse ein neues Asset.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Package className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="font-medium">Keine Assets gefunden.</p>
|
||||
<p className="text-sm mt-2">Passe die Filter an oder erfasse ein neues Asset.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentAsset && (
|
||||
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Header from './Header';
|
||||
import Dashboard from './Dashboard';
|
||||
import DefektForm from './DefektForm';
|
||||
import DefektTable from './DefektTable';
|
||||
import Toast from './Toast';
|
||||
import { useAssets } from '../hooks/useAssets';
|
||||
import { useAuditLog } from '../hooks/useAuditLog';
|
||||
import { useLagerstandorte } from '../hooks/useLagerstandorte';
|
||||
@@ -18,7 +17,7 @@ export default function DefektTrackApp() {
|
||||
const { addLog } = useAuditLog();
|
||||
const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId);
|
||||
const { colleagues } = useColleagues(locationId);
|
||||
const { toast, showToast } = useToast();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const userName = user?.name || user?.email || 'Unbekannt';
|
||||
|
||||
@@ -56,25 +55,52 @@ export default function DefektTrackApp() {
|
||||
}, [assets, changeStatus, addLog, user, userName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<Header assets={assets} showToast={showToast} />
|
||||
<Dashboard
|
||||
assets={assets}
|
||||
lagerstandorte={lagerstandorte}
|
||||
onAddLagerstandort={addLagerstandort}
|
||||
onToggleLagerstandort={toggleLagerstandort}
|
||||
onDeleteLagerstandort={deleteLagerstandort}
|
||||
/>
|
||||
<div className="main">
|
||||
<DefektForm onAdd={handleAdd} showToast={showToast} lagerstandorte={activeLagerstandorte} colleagues={colleagues} />
|
||||
<DefektTable
|
||||
assets={assets}
|
||||
onChangeStatus={handleStatusChange}
|
||||
showToast={showToast}
|
||||
lagerstandorte={lagerstandorte}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar – fixed left */}
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-r bg-background p-4 md:block">
|
||||
<DefektForm
|
||||
onAdd={handleAdd}
|
||||
showToast={showToast}
|
||||
lagerstandorte={activeLagerstandorte}
|
||||
colleagues={colleagues}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content – scrollable */}
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<Dashboard
|
||||
assets={assets}
|
||||
lagerstandorte={lagerstandorte}
|
||||
onAddLagerstandort={addLagerstandort}
|
||||
onToggleLagerstandort={toggleLagerstandort}
|
||||
onDeleteLagerstandort={deleteLagerstandort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-6">
|
||||
<DefektTable
|
||||
assets={assets}
|
||||
onChangeStatus={handleStatusChange}
|
||||
showToast={showToast}
|
||||
lagerstandorte={lagerstandorte}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile: form below table */}
|
||||
<div className="p-4 md:hidden">
|
||||
<DefektForm
|
||||
onAdd={handleAdd}
|
||||
showToast={showToast}
|
||||
lagerstandorte={activeLagerstandorte}
|
||||
colleagues={colleagues}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { databases, DATABASE_ID } from '../lib/appwrite';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import Header from './Header';
|
||||
import Toast from './Toast';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
function getToday() {
|
||||
const d = new Date();
|
||||
@@ -41,7 +44,7 @@ function countInRange(assets, start, end) {
|
||||
|
||||
export default function FilialleiterDashboard() {
|
||||
const { userMeta } = useAuth();
|
||||
const { toast, showToast } = useToast();
|
||||
const { showToast } = useToast();
|
||||
const locationId = userMeta?.locationId || '';
|
||||
|
||||
const [ownAssets, setOwnAssets] = useState([]);
|
||||
@@ -104,111 +107,119 @@ export default function FilialleiterDashboard() {
|
||||
}, [colleagues, ownAssets]);
|
||||
|
||||
function trendArrow(current, previous) {
|
||||
if (current > previous) return { arrow: '▲', cls: 'trend-up' };
|
||||
if (current < previous) return { arrow: '▼', cls: 'trend-down' };
|
||||
return { arrow: '–', cls: 'trend-flat' };
|
||||
if (current > previous) return { arrow: '▲', cls: 'text-green-600' };
|
||||
if (current < previous) return { arrow: '▼', cls: 'text-red-600' };
|
||||
return { arrow: '–', cls: 'text-muted-foreground' };
|
||||
}
|
||||
|
||||
const dayTrend = trendArrow(todayCount, yesterdayCount);
|
||||
const monthTrend = trendArrow(thisMonthCount, lastMonthCount);
|
||||
|
||||
const comparisonMax = Math.max(ownTotal, avgAllFilialen, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="panel-page">
|
||||
<div className="panel-title-bar">
|
||||
<h1>Filialleiter Dashboard</h1>
|
||||
<p>Tägliche und monatliche Übersicht deiner Filiale</p>
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Filialleiter Dashboard</h1>
|
||||
<p className="mt-1 text-muted-foreground">Tägliche und monatliche Übersicht deiner Filiale</p>
|
||||
</div>
|
||||
|
||||
<div className="panel-stats">
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{todayCount}</div>
|
||||
<div className="panel-stat-label">Heute erfasst</div>
|
||||
<div className={`panel-trend ${dayTrend.cls}`}>
|
||||
{dayTrend.arrow} Gestern: {yesterdayCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{thisMonthCount}</div>
|
||||
<div className="panel-stat-label">Diesen Monat</div>
|
||||
<div className={`panel-trend ${monthTrend.cls}`}>
|
||||
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{ownTotal}</div>
|
||||
<div className="panel-stat-label">Meine Filiale</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{avgAllFilialen}</div>
|
||||
<div className="panel-stat-label">⌀ Alle Filialen</div>
|
||||
</div>
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-2">
|
||||
<div className="text-3xl font-bold">{todayCount}</div>
|
||||
<p className="text-sm text-muted-foreground">Heute erfasst</p>
|
||||
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
|
||||
{dayTrend.arrow} Gestern: {yesterdayCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-2">
|
||||
<div className="text-3xl font-bold">{thisMonthCount}</div>
|
||||
<p className="text-sm text-muted-foreground">Diesen Monat</p>
|
||||
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
|
||||
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-2">
|
||||
<div className="text-3xl font-bold">{ownTotal}</div>
|
||||
<p className="text-sm text-muted-foreground">Meine Filiale</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-2">
|
||||
<div className="text-3xl font-bold">{avgAllFilialen}</div>
|
||||
<p className="text-sm text-muted-foreground">⌀ Alle Filialen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="panel-comparison">
|
||||
<h2>Filialvergleich</h2>
|
||||
<div className="comparison-bars">
|
||||
<div className="comparison-row">
|
||||
<span className="comparison-label">Meine Filiale</span>
|
||||
<div className="comparison-bar-bg">
|
||||
<div
|
||||
className="comparison-bar own"
|
||||
style={{ width: `${Math.min(100, avgAllFilialen > 0 ? (ownTotal / avgAllFilialen) * 50 : 50)}%` }}
|
||||
/>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Filialvergleich</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 shrink-0 text-sm font-medium">Meine Filiale</span>
|
||||
<Progress value={Math.round((ownTotal / comparisonMax) * 100)} className="flex-1" />
|
||||
<span className="w-12 text-right text-sm font-semibold tabular-nums">{ownTotal}</span>
|
||||
</div>
|
||||
<span className="comparison-value">{ownTotal}</span>
|
||||
</div>
|
||||
<div className="comparison-row">
|
||||
<span className="comparison-label">⌀ Durchschnitt</span>
|
||||
<div className="comparison-bar-bg">
|
||||
<div className="comparison-bar avg" style={{ width: '50%' }} />
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-32 shrink-0 text-sm font-medium">⌀ Durchschnitt</span>
|
||||
<Progress value={Math.round((avgAllFilialen / comparisonMax) * 100)} className="flex-1" />
|
||||
<span className="w-12 text-right text-sm font-semibold tabular-nums">{avgAllFilialen}</span>
|
||||
</div>
|
||||
<span className="comparison-value">{avgAllFilialen}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="panel-card" style={{ marginTop: 24 }}>
|
||||
<h2>Mitarbeiter-Performance</h2>
|
||||
{employeeStats.length === 0 ? (
|
||||
<p className="panel-empty">Keine Mitarbeiter gefunden</p>
|
||||
) : (
|
||||
<div className="employee-table-wrap">
|
||||
<table className="employee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th>Zugewiesen</th>
|
||||
<th>Offen</th>
|
||||
<th>In Bearbeitung</th>
|
||||
<th>Erledigt</th>
|
||||
<th>Erledigungsrate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mitarbeiter-Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{employeeStats.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">Keine Mitarbeiter gefunden</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Mitarbeiter</TableHead>
|
||||
<TableHead className="text-right">Zugewiesen</TableHead>
|
||||
<TableHead className="text-right">Offen</TableHead>
|
||||
<TableHead className="text-right">In Bearbeitung</TableHead>
|
||||
<TableHead className="text-right">Erledigt</TableHead>
|
||||
<TableHead className="w-48">Erledigungsrate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employeeStats.map((e) => (
|
||||
<tr key={e.name}>
|
||||
<td><strong>{e.name}</strong></td>
|
||||
<td>{e.total}</td>
|
||||
<td>{e.open}</td>
|
||||
<td>{e.inProgress}</td>
|
||||
<td>{e.resolved}</td>
|
||||
<td>
|
||||
<div className="rate-bar-wrap">
|
||||
<div className="rate-bar" style={{ width: `${e.rate}%` }} />
|
||||
<span className="rate-text">{e.rate}%</span>
|
||||
<TableRow key={e.name}>
|
||||
<TableCell className="font-medium">{e.name}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.open}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.inProgress}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.resolved}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={e.rate} className="flex-1" />
|
||||
<span className="w-10 text-right text-xs font-medium tabular-nums">{e.rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { databases, DATABASE_ID } from '../lib/appwrite';
|
||||
import { databases, DATABASE_ID } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import Header from './Header';
|
||||
import Toast from './Toast';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Building2, Users, Package, CheckCircle, AlertCircle, Clock, CircleCheck } from 'lucide-react';
|
||||
|
||||
export default function FirmenleiterDashboard() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [allAssets, setAllAssets] = useState([]);
|
||||
@@ -57,84 +59,139 @@ export default function FirmenleiterDashboard() {
|
||||
return (
|
||||
<>
|
||||
<Header showToast={showToast} />
|
||||
<div className="panel-page">
|
||||
<div className="panel-title-bar">
|
||||
<h1>Firmenleiter Dashboard</h1>
|
||||
<p>Übersicht aller Filialen</p>
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Firmenleiter Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Übersicht aller Filialen</p>
|
||||
</div>
|
||||
|
||||
<div className="panel-stats">
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{locations.length}</div>
|
||||
<div className="panel-stat-label">Filialen</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{allUsers.length}</div>
|
||||
<div className="panel-stat-label">Mitarbeiter gesamt</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">{totalAssets}</div>
|
||||
<div className="panel-stat-label">Assets gesamt</div>
|
||||
</div>
|
||||
<div className="panel-stat-card">
|
||||
<div className="panel-stat-number">
|
||||
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}%
|
||||
</div>
|
||||
<div className="panel-stat-label">Erledigungsrate</div>
|
||||
</div>
|
||||
{/* Main stats */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{locations.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Filialen</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{allUsers.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Mitarbeiter gesamt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{totalAssets}</p>
|
||||
<p className="text-xs text-muted-foreground">Assets gesamt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{totalAssets > 0 ? Math.round((totalResolved / totalAssets) * 100) : 0}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Erledigungsrate</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="panel-stats" style={{ marginTop: 0 }}>
|
||||
<div className="panel-stat-card small">
|
||||
<div className="panel-stat-number" style={{ color: '#C62828' }}>{totalOpen}</div>
|
||||
<div className="panel-stat-label">Offen</div>
|
||||
</div>
|
||||
<div className="panel-stat-card small">
|
||||
<div className="panel-stat-number" style={{ color: '#F9A825' }}>{totalInProgress}</div>
|
||||
<div className="panel-stat-label">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="panel-stat-card small">
|
||||
<div className="panel-stat-number" style={{ color: '#43A047' }}>{totalResolved}</div>
|
||||
<div className="panel-stat-label">Erledigt</div>
|
||||
</div>
|
||||
{/* Status row */}
|
||||
<div className="mb-6 grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-xl font-bold text-red-600">{totalOpen}</p>
|
||||
<p className="text-xs text-muted-foreground">Offen</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<Clock className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-xl font-bold text-amber-600">{totalInProgress}</p>
|
||||
<p className="text-xs text-muted-foreground">In Bearbeitung</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-2">
|
||||
<CircleCheck className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-xl font-bold text-green-600">{totalResolved}</p>
|
||||
<p className="text-xs text-muted-foreground">Erledigt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="panel-card" style={{ marginTop: 24 }}>
|
||||
<h2>Alle Filialen</h2>
|
||||
{filialeStats.length === 0 ? (
|
||||
<p className="panel-empty">Keine Filialen vorhanden</p>
|
||||
) : (
|
||||
<div className="filiale-grid">
|
||||
{filialeStats.map((f) => (
|
||||
<div key={f.id} className={`filiale-card ${f.isActive ? '' : 'inactive'}`}>
|
||||
<div className="filiale-card-header">
|
||||
<h3>{f.name}</h3>
|
||||
<span className={`panel-badge ${f.isActive ? 'active' : 'inactive'}`}>
|
||||
{f.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
{f.address && <p className="filiale-address">{f.address}</p>}
|
||||
<div className="filiale-stats-row">
|
||||
<div className="filiale-mini-stat">
|
||||
<span className="filiale-mini-num">{f.userCount}</span>
|
||||
<span className="filiale-mini-label">Mitarbeiter</span>
|
||||
</div>
|
||||
<div className="filiale-mini-stat">
|
||||
<span className="filiale-mini-num">{f.lsCount}</span>
|
||||
<span className="filiale-mini-label">Lagerstandorte</span>
|
||||
</div>
|
||||
<div className="filiale-mini-stat">
|
||||
<span className="filiale-mini-num">{f.assetsTotal}</span>
|
||||
<span className="filiale-mini-label">Assets</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Filialen section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Filialen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filialeStats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Filialen vorhanden</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filialeStats.map((f) => (
|
||||
<Card key={f.id} className={f.isActive ? '' : 'opacity-60'}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{f.name}</CardTitle>
|
||||
<Badge variant={f.isActive ? 'default' : 'secondary'}>
|
||||
{f.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</div>
|
||||
{f.address && (
|
||||
<p className="text-xs text-muted-foreground">{f.address}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-lg font-semibold">{f.userCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Mitarbeiter</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-lg font-semibold">{f.lsCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Lagerstandorte</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-lg font-semibold">{f.assetsTotal}</span>
|
||||
<span className="text-xs text-muted-foreground">Assets</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Toast message={toast.message} color={toast.color} visible={toast.visible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & Logistik · Defekte Ware im Griff by Justin Klein</div>
|
||||
</div>
|
||||
<div className="header-buttons">
|
||||
{user && (
|
||||
<span className="header-user-info">
|
||||
{user.name || user.email}
|
||||
<span className="header-role-badge">{ROLE_LABELS[role] || role}</span>
|
||||
</span>
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background/95 px-5 py-2.5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
Defekt<span className="text-amber-500">Track</span>
|
||||
</span>
|
||||
{locationName && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="!h-5" />
|
||||
<span className="text-sm text-muted-foreground">{locationName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="header-nav">
|
||||
<div className="flex items-center gap-1">
|
||||
<nav className="flex items-center gap-0.5">
|
||||
{!isOnTracker && (
|
||||
<button className="btn-header btn-nav" onClick={() => navigate('/tracker')}>DefektTrack</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/tracker')}>
|
||||
DefektTrack
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && !isOnAdmin && (
|
||||
<button className="btn-header btn-nav" onClick={() => navigate('/admin')}>Admin Panel</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin')}>
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
{(isFilialleiter || isAdmin) && !isOnFilialleiter && (
|
||||
<button className="btn-header btn-nav" onClick={() => navigate('/filialleiter')}>Filialleiter</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/filialleiter')}>
|
||||
Filialleiter
|
||||
</Button>
|
||||
)}
|
||||
{(isFirmenleiter || isAdmin) && !isOnFirmenleiter && (
|
||||
<button className="btn-header btn-nav" onClick={() => navigate('/firmenleiter')}>Firmenleiter</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/firmenleiter')}>
|
||||
Firmenleiter
|
||||
</Button>
|
||||
)}
|
||||
{isOnTracker && assets && (
|
||||
<button className="btn-header btn-export" onClick={handleExport}>Export</button>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<button className="btn-header btn-logout" onClick={handleLogout}>Logout</button>
|
||||
{user && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="!h-5 mx-1.5" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="hidden sm:inline">{user.name || user.email}</span>
|
||||
<Badge variant="outline" className="ml-0.5 text-[10px] font-medium">
|
||||
{ROLE_LABELS[role] || role}
|
||||
</Badge>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{user.name || user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
@@ -35,40 +40,54 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="logo">Defekt<span>Track</span></div>
|
||||
<p className="login-subtitle">Lager & Logistik · Defekte Ware im Griff</p>
|
||||
</div>
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button type="submit" className="btn-submit" disabled={loading}>
|
||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="text-center space-y-1">
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
Defekt<span className="text-amber-500">Track</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Lager & Logistik · Defekte Ware im Griff
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card" style={{ textAlign: 'center' }}>
|
||||
<div className="logo" style={{ marginBottom: '12px' }}>Defekt<span>Track</span></div>
|
||||
<p style={{ color: '#888' }}>Lade...</p>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-10">
|
||||
<p className="text-2xl font-bold tracking-tight">
|
||||
Defekt<span className="text-amber-500">Track</span>
|
||||
</p>
|
||||
<div className="w-full space-y-3">
|
||||
<Skeleton className="h-4 w-3/4 mx-auto" />
|
||||
<Skeleton className="h-4 w-1/2 mx-auto" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export default function Toast({ message, color, visible }) {
|
||||
return (
|
||||
<div
|
||||
className={`toast ${visible ? 'show' : ''}`}
|
||||
style={{ background: color }}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/badge.jsx
Normal file
49
src/components/ui/badge.jsx
Normal 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
114
src/components/ui/card.jsx
Normal 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,
|
||||
}
|
||||
155
src/components/ui/dialog.jsx
Normal file
155
src/components/ui/dialog.jsx
Normal 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,
|
||||
}
|
||||
252
src/components/ui/dropdown-menu.jsx
Normal file
252
src/components/ui/dropdown-menu.jsx
Normal 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,
|
||||
}
|
||||
23
src/components/ui/input.jsx
Normal file
23
src/components/ui/input.jsx
Normal 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 }
|
||||
20
src/components/ui/label.jsx
Normal file
20
src/components/ui/label.jsx
Normal 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 }
|
||||
89
src/components/ui/popover.jsx
Normal file
89
src/components/ui/popover.jsx
Normal 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,
|
||||
}
|
||||
84
src/components/ui/progress.jsx
Normal file
84
src/components/ui/progress.jsx
Normal 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,
|
||||
}
|
||||
46
src/components/ui/scroll-area.jsx
Normal file
46
src/components/ui/scroll-area.jsx
Normal 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 }
|
||||
191
src/components/ui/select.jsx
Normal file
191
src/components/ui/select.jsx
Normal 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,
|
||||
}
|
||||
22
src/components/ui/separator.jsx
Normal file
22
src/components/ui/separator.jsx
Normal 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 }
|
||||
15
src/components/ui/skeleton.jsx
Normal file
15
src/components/ui/skeleton.jsx
Normal 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 }
|
||||
48
src/components/ui/sonner.jsx
Normal file
48
src/components/ui/sonner.jsx
Normal 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
123
src/components/ui/table.jsx
Normal 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,
|
||||
}
|
||||
20
src/components/ui/textarea.jsx
Normal file
20
src/components/ui/textarea.jsx
Normal 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 }
|
||||
59
src/components/ui/tooltip.jsx
Normal file
59
src/components/ui/tooltip.jsx
Normal 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 }
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import App from './App.jsx'
|
||||
import './App.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
187
todos.md
187
todos.md
@@ -6,162 +6,179 @@ Ziel: Aufbau eines sicheren, rollenbasierten Defekt- und Retouren-Management-Sys
|
||||
|
||||
# PRIORITY 1 – CORE SECURITY & ACCESS
|
||||
|
||||
## 1. Login-Gate vor dem Laden der App
|
||||
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
|
||||
- [x] ## 1. Login-Gate vor dem Laden der App
|
||||
Die eigentliche Anwendung darf erst geladen werden, nachdem sich ein Nutzer erfolgreich authentifiziert hat.
|
||||
|
||||
## 2. Session-System (Login bleibt bis Browser geschlossen wird)
|
||||
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
|
||||
- [x] ## 2. Session-System (Login bleibt bis Browser geschlossen wird)
|
||||
Der Login bleibt aktiv, bis der Browser geschlossen wird, damit Mitarbeiter nicht ständig neu einloggen müssen.
|
||||
|
||||
## 3. Benutzerverwaltung (Admin)
|
||||
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
|
||||
- [x] ## 3. Benutzerverwaltung (Admin)
|
||||
Ein Administrator muss neue Benutzer anlegen, deaktivieren und verwalten können.
|
||||
|
||||
## 4. Rollen-System
|
||||
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
|
||||
- [x] ## 4. Rollen-System
|
||||
Jeder Benutzer erhält intern eine Rolle (z.B. Lager, Service, Filialleiter, Firmenleiter), die bestimmt, welche Funktionen und Ansichten sichtbar sind.
|
||||
|
||||
## 5. Startpasswort-System
|
||||
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
|
||||
- [ ] ## 5. Startpasswort-System
|
||||
Neue Benutzer erhalten ein Standardpasswort (z.B. 0000), das nach dem ersten Login geändert werden muss.
|
||||
|
||||
## 6. Passwort-Änderungspflicht
|
||||
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
|
||||
- [ ] ## 6. Passwort-Änderungspflicht
|
||||
Startpasswörter müssen innerhalb von 24 Stunden geändert werden, sonst wird eine Warnung oder Benachrichtigung ausgelöst.
|
||||
|
||||
## 7. PIN-Login-System
|
||||
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
|
||||
- [ ] ## 7. PIN-Login-System
|
||||
Login erfolgt über Benutzername + 4-stelligen PIN, den der Benutzer nach dem ersten Login selbst festlegt.
|
||||
|
||||
## 8. Passwort-Hashing
|
||||
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden.
|
||||
- [x] ## 8. Passwort-Hashing
|
||||
Passwörter oder PINs dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht gespeichert werden (Appwrite übernimmt dies).
|
||||
|
||||
## 9. Zugriffskontrolle (Role Based Access Control)
|
||||
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
|
||||
- [x] ## 9. Zugriffskontrolle (Role Based Access Control)
|
||||
Das Backend muss prüfen, ob ein Benutzer berechtigt ist, eine Aktion auszuführen.
|
||||
|
||||
## 10. Audit-Log
|
||||
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
|
||||
- [x] ## 10. Audit-Log
|
||||
Alle wichtigen Aktionen (Login, Statusänderung, Löschen, Benutzeränderungen) müssen protokolliert werden.
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 2 – USER EXPERIENCE & DASHBOARDS
|
||||
|
||||
## 11. Rollenbasierte Startseiten
|
||||
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
|
||||
- [x] ## 11. Rollenbasierte Startseiten
|
||||
Nach dem Login erhält jeder Benutzer eine andere Startseite abhängig von seiner Rolle.
|
||||
|
||||
## 12. Lagerkraft-Startseite
|
||||
Zeigt primär offene Artikel und operative Aufgaben.
|
||||
- [x] ## 12. Lagerkraft-Startseite
|
||||
Zeigt primär offene Artikel und operative Aufgaben (Tracker).
|
||||
|
||||
## 13. Service-Startseite
|
||||
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare.
|
||||
- [x] ## 13. Service-Startseite
|
||||
Zeigt Artikel in Bearbeitung, technische Prüfungen und Kommentare (Tracker).
|
||||
|
||||
## 14. Filialleiter-Dashboard
|
||||
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
|
||||
- [x] ## 14. Filialleiter-Dashboard
|
||||
Zeigt Statistiken und Übersicht über alle Defektfälle der Filiale.
|
||||
|
||||
## 15. Firmenleiter-Dashboard
|
||||
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
|
||||
- [x] ## 15. Firmenleiter-Dashboard
|
||||
Zeigt Gesamtstatistiken über alle Filialen und Unternehmensdaten.
|
||||
|
||||
## 16. Automatische Filter je Rolle
|
||||
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
|
||||
- [ ] ## 16. Automatische Filter je Rolle
|
||||
Standardfilter werden automatisch gesetzt (z.B. Lager sieht offene Fälle zuerst).
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 3 – DEFECT MANAGEMENT CORE
|
||||
|
||||
## 17. Defektfall-System
|
||||
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen.
|
||||
- [x] ## 17. Defektfall-System
|
||||
Das zentrale Objekt der App ist ein Defektfall mit Artikel-, Serien- und Fehlerinformationen (Assets-Collection).
|
||||
|
||||
## 18. Status-Workflow
|
||||
Statussystem für Fälle (Offen → In Bearbeitung → Erledigt → Entsorgt).
|
||||
- [x] ## 18. Status-Workflow
|
||||
Statussystem für Fälle (Offen → In Bearbeitung → Entsorgt).
|
||||
|
||||
## 19. Prioritätssystem
|
||||
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
|
||||
- [x] ## 19. Prioritätssystem
|
||||
Fälle erhalten Prioritäten (niedrig, mittel, hoch, kritisch).
|
||||
|
||||
## 20. Verantwortlichkeits-System
|
||||
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden.
|
||||
- [x] ## 20. Verantwortlichkeits-System
|
||||
Jeder Defektfall muss einem Mitarbeiter zugewiesen werden (Zuständig-Dropdown aus Appwrite-Benutzern der Filiale).
|
||||
|
||||
## 21. Kommentar-System
|
||||
Interne Kommentare und technische Notizen zu jedem Defektfall.
|
||||
- [x] ## 21. Kommentar-System
|
||||
Interne Kommentare und technische Notizen zu jedem Defektfall (Kommentar-Feld, CommentPopup für Anzeige).
|
||||
|
||||
## 22. Defekt-Historie
|
||||
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden.
|
||||
- [x] ## 22. Defekt-Historie
|
||||
Alle Änderungen eines Falls müssen nachvollziehbar gespeichert werden (Audit-Log pro Asset).
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 4 – SEARCH & FILTERING
|
||||
|
||||
## 23. Erweiterte Suche
|
||||
Suche nach ERL-Nummer, Seriennummer, Artikelnummer oder Beschreibung.
|
||||
- [x] ## 23. Erweiterte Suche
|
||||
Suche nach ERL-Nummer, Artikelnummer, Seriennummer, Defektbeschreibung.
|
||||
|
||||
## 24. Statusfilter
|
||||
Filter für offene, in Bearbeitung befindliche, erledigte oder entsorgte Artikel.
|
||||
- [x] ## 24. Statusfilter
|
||||
Filter für offene, in Bearbeitung befindliche, entsorgte Artikel.
|
||||
|
||||
## 25. Prioritätsfilter
|
||||
Filter für kritische oder wichtige Fälle.
|
||||
- [x] ## 25. Prioritätsfilter
|
||||
Filter/Sortierung nach Priorität (kritisch, hoch, mittel, niedrig).
|
||||
|
||||
## 26. Mitarbeiterfilter
|
||||
Anzeige der Fälle nach zuständigem Mitarbeiter.
|
||||
- [x] ## 26. Mitarbeiterfilter
|
||||
Anzeige der Fälle nach zuständigem Mitarbeiter (Sortierung „Mir zugewiesen“).
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 5 – STATISTICS & ANALYTICS
|
||||
|
||||
## 27. Mitarbeiterstatistiken
|
||||
Eigene offenen, erledigten und überfälligen Fälle eines Mitarbeiters.
|
||||
- [x] ## 27. Mitarbeiterstatistiken
|
||||
Eigene offenen, erledigten und überfälligen Fälle (Filialleiter-Dashboard: Mitarbeiter-Performance mit Erledigungsrate).
|
||||
|
||||
## 28. Filialstatistiken
|
||||
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
|
||||
- [x] ## 28. Filialstatistiken
|
||||
Übersicht über Defektfälle und Bearbeitungsstatus innerhalb einer Filiale.
|
||||
|
||||
## 29. Unternehmensstatistiken
|
||||
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen.
|
||||
- [x] ## 29. Unternehmensstatistiken
|
||||
Gesamtübersicht aller Filialen mit Vergleich der Leistungskennzahlen (Firmenleiter-Dashboard).
|
||||
|
||||
## 30. Bearbeitungszeit-Analyse
|
||||
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
|
||||
- [ ] ## 30. Bearbeitungszeit-Analyse
|
||||
Durchschnittliche Dauer vom Anlegen bis zur Lösung eines Defektfalls.
|
||||
|
||||
## 31. Häufigste Defekte
|
||||
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
|
||||
- [ ] ## 31. Häufigste Defekte
|
||||
Statistik über häufig auftretende Fehlerarten oder Artikelprobleme.
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 6 – ORGANISATION STRUCTURE
|
||||
|
||||
## 32. Filial-System
|
||||
Unterstützung mehrerer Standorte innerhalb eines Unternehmens.
|
||||
- [x] ## 32. Filial-System
|
||||
Unterstützung mehrerer Standorte innerhalb eines Unternehmens (locations-Collection, Admin verwaltet Filialen).
|
||||
|
||||
## 33. Standortzuweisung für Benutzer
|
||||
Benutzer gehören zu einer bestimmten Filiale.
|
||||
- [x] ## 33. Standortzuweisung für Benutzer
|
||||
Benutzer gehören zu einer bestimmten Filiale (users_meta.locationId).
|
||||
|
||||
## 34. Standortfilter für Daten
|
||||
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
|
||||
- [x] ## 34. Standortfilter für Daten
|
||||
Filialleiter sehen nur Daten ihrer Filiale, Firmenleiter sehen alle Daten.
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 7 – SYSTEM FEATURES
|
||||
|
||||
## 35. Export-Funktion
|
||||
Datenexport für Berichte oder Archivierung.
|
||||
- [x] ## 35. Export-Funktion
|
||||
Datenexport für Berichte oder Archivierung (JSON-Export im Header).
|
||||
|
||||
## 36. Import-Funktion
|
||||
Import von Datensätzen für Migration oder Synchronisation.
|
||||
- [ ] ## 36. Import-Funktion
|
||||
Import von Datensätzen für Migration oder Synchronisation.
|
||||
|
||||
## 37. Druckansicht
|
||||
Optimierte Druckansicht für Berichte oder Listen.
|
||||
- [x] ## 37. Druckansicht
|
||||
Optimierte Druckansicht für Berichte oder Listen (Drucken-Button in der Asset-Tabelle).
|
||||
|
||||
## 38. Benachrichtigungen
|
||||
Systemmeldungen bei kritischen oder überfälligen Defektfällen.
|
||||
- [ ] ## 38. Benachrichtigungen
|
||||
Systemmeldungen bei kritischen oder überfälligen Defektfällen (Toasts vorhanden, keine gezielten Alerts).
|
||||
|
||||
---
|
||||
|
||||
# PRIORITY 8 – FUTURE FEATURES
|
||||
|
||||
## 39. Datei-Uploads
|
||||
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
|
||||
- [ ] ## 39. Datei-Uploads
|
||||
Anhänge wie Fotos von Schäden oder Dokumente zu Defektfällen.
|
||||
|
||||
## 40. Mobile Optimierung
|
||||
Optimierte Nutzung für Tablets oder mobile Geräte im Lager.
|
||||
- [x] ## 40. Mobile Optimierung
|
||||
Optimierte Nutzung für Tablets oder mobile Geräte im Lager (responsive Layout, Sidebar ausgeblendet auf Mobile, Form unten).
|
||||
|
||||
## 41. API-Schnittstellen
|
||||
Möglichkeit zur Integration mit anderen Systemen.
|
||||
- [ ] ## 41. API-Schnittstellen
|
||||
Möglichkeit zur Integration mit anderen Systemen.
|
||||
|
||||
## 42. Automatische Eskalationen
|
||||
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
|
||||
- [ ] ## 42. Automatische Eskalationen
|
||||
Fälle werden automatisch markiert, wenn sie zu lange unbearbeitet bleiben.
|
||||
|
||||
---
|
||||
|
||||
# ERGÄNZUNGEN (bereits umgesetzt, nicht im Original-Roadmap)
|
||||
|
||||
- [x] **Appwrite-Integration** – Auth, Teams, Databases; Collections: locations, users_meta, lagerstandorte, assets, audit_logs.
|
||||
- [x] **Produkte → Assets** – Umbenennung, eigene Collection, Verknüpfung mit Lagerstandort und Location.
|
||||
- [x] **Lagerstandorte** – Pro Filiale mehrere Lagerstandorte, verwaltbar über Button im Dashboard (LagerstandortManager).
|
||||
- [x] **Asset-Detailseite** – Eigene Seite `/asset/:id` mit allen Eigenschaften, Bearbeiten-Modus, Audit-Log (Konsolen-Style).
|
||||
- [x] **Bearbeiten statt Löschen** – Button „Bearbeiten“ öffnet Asset-Detailseite.
|
||||
- [x] **Header** – Standortname neben DefektTrack, Navigation Admin / Filialleiter / Firmenleiter, User-Dropdown mit Logout, Export.
|
||||
- [x] **Admin: Filialen verwalten** – Filialen anlegen, bearbeiten, aktivieren/deaktivieren, löschen.
|
||||
- [x] **Zuständig-Dropdown** – Default eigener Name, Auswahl nur Mitarbeiter der gleichen Filiale (Appwrite).
|
||||
- [x] **Audit-Log Erfassung** – Bei Erstellung: „für sich selbst erfasst“ vs. „von X für Y erfasst“; bei Bearbeitung und Statusänderung.
|
||||
- [x] **UI-Redesign mit shadcn/ui** – Button, Input, Card, Table, Badge, Dialog, Select, Sonner-Toast, einheitliches Design.
|
||||
- [x] **Header clean/minimal** – Heller Header, keine horizontale Scrollbar, User-Dropdown mit DropdownMenuGroup-Fix.
|
||||
- [x] **Layout: fixe Sidebar links** – „Defekte Ware erfassen“ als fixe linke Sidebar (380px), rechts Status-Karten + Asset-Tabelle, kein horizontaler Scroll.
|
||||
|
||||
---
|
||||
|
||||
# PROJECT GOAL
|
||||
|
||||
Ein sicheres, rollenbasiertes System zur Verwaltung defekter Artikel in Lager, Service und Management mit klaren Verantwortlichkeiten, Nachverfolgbarkeit und statistischen Auswertungen.
|
||||
Ein sicheres, rollenbasiertes System zur Verwaltung defekter Artikel in Lager, Service und Management mit klaren Verantwortlichkeiten, Nachverfolgbarkeit und statistischen Auswertungen.
|
||||
|
||||
Reference in New Issue
Block a user