All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30m12s
- admin/page.tsx: 1365 -> 224 Zeilen (nur noch Guard + Header + Tabs -> Komponenten) - Neue in sich geschlossene Komponenten (eigener State + Daten-Laden): tenants-tab, users-tab, projects-tab, icons-tab, categories-tab - Gemeinsame Typen/Konstanten in admin-constants.ts zentralisiert - SettingsTab lädt Zähler selbst (von der Hülle entkoppelt) - Toten Code entfernt (UpgradeRequestsTab) - Ergänzt: Benutzer-Suche + Mandanten-Auswahl beim Anlegen (verhindert verwaiste User) - Version 1.4.7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
12 KiB
TypeScript
236 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { Upload, Pencil, Trash2, Eye, EyeOff, Image as ImageIcon, Loader2 } from 'lucide-react'
|
|
import { ICON_TYPES, type IconAsset, type IconCategory } from './admin-constants'
|
|
|
|
export function IconsTab() {
|
|
const { toast } = useToast()
|
|
const [icons, setIcons] = useState<IconAsset[]>([])
|
|
const [categories, setCategories] = useState<IconCategory[]>([])
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
|
|
|
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)
|
|
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null)
|
|
const [uploadCategory, setUploadCategory] = useState('')
|
|
const [uploadIconType, setUploadIconType] = useState('STANDARD')
|
|
const [uploadIconName, setUploadIconName] = useState('')
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
|
const [editingIcon, setEditingIcon] = useState<IconAsset | null>(null)
|
|
const [editIconName, setEditIconName] = useState('')
|
|
const [editIconCategory, setEditIconCategory] = useState('')
|
|
const [editIconType, setEditIconType] = useState('STANDARD')
|
|
const [editIconTags, setEditIconTags] = useState('')
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [iconRes, catRes] = await Promise.all([
|
|
fetch('/api/admin/icons'),
|
|
fetch('/api/admin/categories'),
|
|
])
|
|
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
|
|
if (catRes.ok) setCategories((await catRes.json()).categories || [])
|
|
} catch (e) {
|
|
console.error('Error fetching icons:', e)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { fetchData() }, [fetchData])
|
|
|
|
const handleUploadIcons = async () => {
|
|
if (!uploadFiles || !uploadCategory) return
|
|
setIsUploading(true)
|
|
try {
|
|
for (const file of Array.from(uploadFiles)) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('categoryId', uploadCategory)
|
|
formData.append('iconType', uploadIconType)
|
|
formData.append('name', uploadIconName.trim() || file.name.replace(/\.(png|svg|jpg|jpeg|webp)$/i, ''))
|
|
const res = await fetch('/api/admin/icons/upload', { method: 'POST', body: formData })
|
|
if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Upload fehlgeschlagen') }
|
|
}
|
|
toast({ title: `${uploadFiles.length} Icon(s) hochgeladen` })
|
|
setIsUploadDialogOpen(false); setUploadFiles(null); setUploadCategory(''); setUploadIconName('')
|
|
fetchData()
|
|
} catch (error) {
|
|
toast({ title: 'Upload-Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
|
} finally { setIsUploading(false) }
|
|
}
|
|
|
|
const handleEditIcon = (icon: IconAsset) => {
|
|
setEditingIcon(icon); setEditIconName(icon.name); setEditIconCategory(icon.category.id)
|
|
setEditIconType(icon.iconType); setEditIconTags(icon.tags?.join(', ') || '')
|
|
setIsEditDialogOpen(true)
|
|
}
|
|
|
|
const handleSaveIcon = async () => {
|
|
if (!editingIcon) return
|
|
try {
|
|
const res = await fetch(`/api/admin/icons/${editingIcon.id}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: editIconName, categoryId: editIconCategory, iconType: editIconType,
|
|
tags: editIconTags.split(',').map(t => t.trim()).filter(Boolean),
|
|
}),
|
|
})
|
|
if (res.ok) { toast({ title: 'Icon aktualisiert' }); setIsEditDialogOpen(false); setEditingIcon(null); fetchData() }
|
|
else { const err = await res.json(); throw new Error(err.error) }
|
|
} catch (error) {
|
|
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
|
}
|
|
}
|
|
|
|
const handleToggleIconActive = async (icon: IconAsset) => {
|
|
try {
|
|
const res = await fetch(`/api/admin/icons/${icon.id}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ isActive: !icon.isActive }),
|
|
})
|
|
if (res.ok) { toast({ title: icon.isActive ? 'Symbol deaktiviert' : 'Symbol aktiviert' }); fetchData() }
|
|
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
|
|
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
|
}
|
|
|
|
const handleDeleteIcon = async (id: string) => {
|
|
if (!confirm('Icon wirklich löschen?')) return
|
|
try {
|
|
const res = await fetch(`/api/admin/icons/${id}`, { method: 'DELETE' })
|
|
if (res.ok) { toast({ title: 'Icon gelöscht' }); fetchData() }
|
|
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
|
}
|
|
|
|
const filteredIcons = selectedCategory === 'all' ? icons : icons.filter(i => i.category.id === selectedCategory)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
|
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Kategorie filtern" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Alle Kategorien</SelectItem>
|
|
{categories.map(cat => <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-sm text-muted-foreground">{filteredIcons.length} Symbol(e)</span>
|
|
</div>
|
|
<Button onClick={() => setIsUploadDialogOpen(true)}>
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
Icons hochladen
|
|
</Button>
|
|
</div>
|
|
|
|
{filteredIcons.length === 0 ? (
|
|
<div className="border-2 border-dashed rounded-lg p-12 text-center">
|
|
<ImageIcon className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
|
<h3 className="font-medium text-lg mb-2">Keine Symbole vorhanden</h3>
|
|
<p className="text-muted-foreground mb-4">Laden Sie eigene Symbole hoch (PNG, SVG, JPEG)</p>
|
|
<Button onClick={() => setIsUploadDialogOpen(true)}><Upload className="w-4 h-4 mr-2" />Jetzt hochladen</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
|
{filteredIcons.map(icon => (
|
|
<div key={icon.id} className={`relative group border rounded-lg p-3 transition-all hover:shadow-md ${!icon.isActive ? 'opacity-50' : ''}`}>
|
|
<div className="aspect-square flex items-center justify-center mb-2 bg-muted rounded">
|
|
<img src={`/api/icons/${icon.id}/image`} alt={icon.name} className="w-12 h-12 object-contain" />
|
|
</div>
|
|
<p className="text-xs text-center truncate font-medium" title={icon.name}>{icon.name}</p>
|
|
<p className="text-[10px] text-center text-muted-foreground truncate">{icon.category.name}</p>
|
|
{icon.isSystem && <p className="text-[10px] text-center text-blue-500">System</p>}
|
|
<div className="absolute inset-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 rounded-lg">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEditIcon(icon)}><Pencil className="w-4 h-4" /></Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleToggleIconActive(icon)}>
|
|
{icon.isActive ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDeleteIcon(icon.id)}><Trash2 className="w-4 h-4" /></Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload Dialog */}
|
|
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Icons hochladen</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>Dateien (PNG, SVG, JPEG)</Label>
|
|
<Input type="file" accept=".png,.svg,.jpg,.jpeg,.webp" multiple onChange={e => setUploadFiles(e.target.files)} />
|
|
</div>
|
|
<div>
|
|
<Label>Symbolname (optional, sonst Dateiname)</Label>
|
|
<Input value={uploadIconName} onChange={e => setUploadIconName(e.target.value)} placeholder="z.B. Feuerwehrauto TLF" />
|
|
</div>
|
|
<div>
|
|
<Label>Kategorie</Label>
|
|
<Select value={uploadCategory} onValueChange={setUploadCategory}>
|
|
<SelectTrigger><SelectValue placeholder="Kategorie wählen" /></SelectTrigger>
|
|
<SelectContent>{categories.map(cat => <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Symbol-Typ</Label>
|
|
<Select value={uploadIconType} onValueChange={setUploadIconType}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{ICON_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsUploadDialogOpen(false)}>Abbrechen</Button>
|
|
<Button onClick={handleUploadIcons} disabled={!uploadFiles || !uploadCategory || isUploading}>
|
|
{isUploading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Hochladen...</> : 'Hochladen'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Icon Dialog */}
|
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Icon bearbeiten</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
{editingIcon && (
|
|
<div className="flex justify-center"><img src={`/api/icons/${editingIcon.id}/image`} alt={editingIcon.name} className="w-16 h-16 object-contain bg-muted rounded-lg p-2" /></div>
|
|
)}
|
|
<div><Label>Name</Label><Input value={editIconName} onChange={e => setEditIconName(e.target.value)} /></div>
|
|
<div>
|
|
<Label>Kategorie</Label>
|
|
<Select value={editIconCategory} onValueChange={setEditIconCategory}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{categories.map(cat => <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Symbol-Typ</Label>
|
|
<Select value={editIconType} onValueChange={setEditIconType}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>{ICON_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div><Label>Tags (kommagetrennt)</Label><Input value={editIconTags} onChange={e => setEditIconTags(e.target.value)} placeholder="z.B. feuerwehr, fahrzeug" /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Abbrechen</Button>
|
|
<Button onClick={handleSaveIcon} disabled={!editIconName.trim()}>Speichern</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|