348 lines
20 KiB
TypeScript
348 lines
20 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { Expert } from '../types';
|
|
import { supabase, TABLES } from '../lib/supabase';
|
|
|
|
interface ExpertsProps {
|
|
experts: Expert[];
|
|
setExperts: React.Dispatch<React.SetStateAction<Expert[]>>;
|
|
refreshData: () => Promise<void>;
|
|
}
|
|
|
|
export const Experts: React.FC<ExpertsProps> = ({ experts, refreshData }) => {
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingExpert, setEditingExpert] = useState<Expert | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [expertToDeleteId, setExpertToDeleteId] = useState<string | null>(null);
|
|
const [tagInput, setTagInput] = useState('');
|
|
|
|
const [formState, setFormState] = useState({
|
|
name: '',
|
|
role: '',
|
|
email: '',
|
|
phone: '',
|
|
linkedin: '',
|
|
status: 'Verfügbar' as Expert['status'],
|
|
avatar: '',
|
|
bio: '',
|
|
tags: [] as string[]
|
|
});
|
|
|
|
const filteredExperts = experts.filter(e =>
|
|
e.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
e.role.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
e.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
);
|
|
|
|
const openEditModal = (expert: Expert) => {
|
|
setEditingExpert(expert);
|
|
setFormState({
|
|
name: expert.name,
|
|
role: expert.role,
|
|
email: expert.email || '',
|
|
phone: expert.phone || '',
|
|
linkedin: expert.linkedin || '',
|
|
status: expert.status,
|
|
avatar: expert.avatar || '',
|
|
bio: expert.bio || '',
|
|
tags: expert.tags || []
|
|
});
|
|
setTagInput('');
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const openCreateModal = () => {
|
|
setEditingExpert(null);
|
|
setFormState({
|
|
name: '',
|
|
role: '',
|
|
email: '',
|
|
phone: '',
|
|
linkedin: '',
|
|
status: 'Verfügbar',
|
|
avatar: '',
|
|
bio: '',
|
|
tags: []
|
|
});
|
|
setTagInput('');
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleAddTag = (e?: React.FormEvent) => {
|
|
if (e) e.preventDefault();
|
|
const tag = tagInput.trim();
|
|
if (tag && !formState.tags.includes(tag)) {
|
|
setFormState({ ...formState, tags: [...formState.tags, tag] });
|
|
setTagInput('');
|
|
}
|
|
};
|
|
|
|
const removeTag = (tagToRemove: string) => {
|
|
setFormState({ ...formState, tags: formState.tags.filter(t => t !== tagToRemove) });
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (editingExpert) {
|
|
const { error } = await supabase
|
|
.from(TABLES.EXPERTS)
|
|
.update(formState)
|
|
.eq('id', editingExpert.id);
|
|
if (error) console.error(error);
|
|
} else {
|
|
const { error } = await supabase.from(TABLES.EXPERTS).insert([formState]);
|
|
if (error) console.error(error);
|
|
}
|
|
|
|
setIsModalOpen(false);
|
|
refreshData();
|
|
};
|
|
|
|
const confirmDeleteExpert = async () => {
|
|
if (!expertToDeleteId) return;
|
|
try {
|
|
await supabase.from(TABLES.EXPERTS).delete().eq('id', expertToDeleteId);
|
|
await refreshData();
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
} finally {
|
|
setExpertToDeleteId(null);
|
|
}
|
|
};
|
|
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setFormState({ ...formState, avatar: reader.result as string });
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center mb-8">
|
|
<div className="relative flex-1 w-full max-w-xl">
|
|
<input
|
|
type="text"
|
|
placeholder="Namen, Rollen oder Tags suchen..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-12 pr-6 py-4 bg-white dark:bg-slate-900 border-none rounded-2xl shadow-sm focus:ring-2 focus:ring-brand-500 transition-all text-lg dark:text-white"
|
|
/>
|
|
<svg className="w-6 h-6 absolute left-4 top-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
|
</div>
|
|
<button
|
|
onClick={openCreateModal}
|
|
className="bg-brand-600 hover:bg-brand-700 text-white px-6 py-4 rounded-2xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 whitespace-nowrap w-full md:w-auto justify-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
|
Experte hinzufügen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
{filteredExperts.map((expert) => (
|
|
<div key={expert.id} className="bg-white dark:bg-slate-900 rounded-[2.5rem] p-6 border border-slate-100 dark:border-slate-800 hover:shadow-2xl transition-all group flex flex-col relative overflow-hidden h-full">
|
|
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => setExpertToDeleteId(expert.id)}
|
|
className="p-2 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm text-slate-400 hover:text-red-500 rounded-full shadow-sm"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center text-center mb-6">
|
|
<div className="relative mb-4">
|
|
{expert.avatar ? (
|
|
<img src={expert.avatar} alt={expert.name} className="w-24 h-24 rounded-[2rem] object-cover shadow-xl ring-4 ring-slate-50 dark:ring-slate-800" />
|
|
) : (
|
|
<div className="w-24 h-24 rounded-[2rem] bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-3xl uppercase ring-4 ring-slate-50 dark:ring-slate-800">
|
|
{expert.name.charAt(0)}
|
|
</div>
|
|
)}
|
|
<div className={`absolute -bottom-1 -right-1 w-6 h-6 rounded-full border-4 border-white dark:border-slate-900 ${
|
|
expert.status === 'Verfügbar' ? 'bg-brand-500' :
|
|
expert.status === 'Gebucht' ? 'bg-blue-500' : 'bg-slate-400'
|
|
}`}></div>
|
|
</div>
|
|
<h4 className="font-bold text-xl leading-tight text-slate-900 dark:text-white group-hover:text-brand-500 transition-colors">{expert.name}</h4>
|
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{expert.role}</p>
|
|
</div>
|
|
|
|
<div className="flex-1 mb-4 flex flex-col">
|
|
<div className="p-4 bg-slate-50 dark:bg-slate-800/40 rounded-2xl border border-slate-100 dark:border-slate-800/50 min-h-[80px] mb-4">
|
|
<p className="text-slate-600 dark:text-slate-400 text-xs leading-relaxed line-clamp-3 italic">
|
|
{expert.bio || "Keine zusätzlichen Informationen hinterlegt."}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tags Display moved below bio Box */}
|
|
{expert.tags && expert.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 justify-center mb-2">
|
|
{expert.tags.slice(0, 4).map(tag => (
|
|
<span key={tag} className="px-2.5 py-1 bg-brand-50 dark:bg-brand-500/10 text-[9px] font-extrabold text-brand-700 dark:text-brand-400 rounded-lg uppercase tracking-wider border border-brand-100 dark:border-brand-500/20 shadow-sm">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{expert.tags.length > 4 && (
|
|
<span className="text-[9px] font-bold text-slate-400 self-center">+{expert.tags.length - 4}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-6 text-[13px]">
|
|
<div className="flex items-center gap-3 text-slate-500">
|
|
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
|
<span className="truncate">{expert.email || 'n/a'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-slate-500">
|
|
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
|
|
<span className="truncate">{expert.phone || 'n/a'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => openEditModal(expert)}
|
|
className="w-full py-3.5 bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 font-bold rounded-2xl hover:bg-brand-600 dark:hover:bg-brand-600 transition-all text-xs uppercase tracking-widest shadow-lg shadow-slate-900/10"
|
|
>
|
|
Profil bearbeiten
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{filteredExperts.length === 0 && (
|
|
<div className="col-span-full py-24 text-center bg-white dark:bg-slate-900 rounded-[3rem] border-2 border-dashed border-slate-200 dark:border-slate-800">
|
|
<div className="w-20 h-20 bg-slate-50 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
|
|
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
|
</div>
|
|
<p className="text-slate-500 font-medium mb-4">Keine Experten gefunden.</p>
|
|
<button onClick={openCreateModal} className="text-brand-600 font-bold hover:underline px-6 py-2 bg-brand-50 dark:bg-brand-500/10 rounded-xl transition-all">Neu anlegen</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
|
<div className="bg-white dark:bg-slate-900 w-full max-w-2xl rounded-[2.5rem] shadow-2xl p-10 max-h-[90vh] overflow-y-auto border border-slate-100 dark:border-slate-800">
|
|
<div className="flex justify-between items-center mb-8">
|
|
<h3 className="text-3xl font-bold dark:text-white">{editingExpert ? 'Profil bearbeiten' : 'Neuer Experte'}</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="md:col-span-2 flex flex-col items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-6 rounded-3xl border border-dashed border-slate-200 dark:border-slate-700">
|
|
<div className="relative group">
|
|
{formState.avatar ? (
|
|
<img src={formState.avatar} alt="Preview" className="w-32 h-32 rounded-[2rem] object-cover shadow-xl" />
|
|
) : (
|
|
<div className="w-32 h-32 rounded-[2rem] bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-400">
|
|
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
</div>
|
|
)}
|
|
<label className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-[2rem] opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity font-bold text-xs uppercase">
|
|
Ändern
|
|
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
|
|
</label>
|
|
</div>
|
|
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-widest">Foto hochladen</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Name</label>
|
|
<input required type="text" value={formState.name} onChange={e => setFormState({...formState, name: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Rolle</label>
|
|
<input required type="text" value={formState.role} onChange={e => setFormState({...formState, role: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">E-Mail</label>
|
|
<input type="email" value={formState.email} onChange={e => setFormState({...formState, email: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Telefon</label>
|
|
<input type="text" value={formState.phone} onChange={e => setFormState({...formState, phone: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
|
|
</div>
|
|
|
|
{/* Tags Input Section */}
|
|
<div className="md:col-span-2 pt-4">
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-3 tracking-widest">Themenbereiche & Expertisen (Tags)</label>
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
{formState.tags.map(tag => (
|
|
<span key={tag} className="flex items-center gap-2 px-3 py-1.5 bg-brand-500 text-white rounded-xl text-xs font-bold shadow-md shadow-brand-500/10">
|
|
{tag}
|
|
<button type="button" onClick={() => removeTag(tag)} className="hover:text-slate-200 transition-colors">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={tagInput}
|
|
onChange={(e) => setTagInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(e); }}
|
|
placeholder="Tag eingeben (z.B. Cloud Computing)..."
|
|
className="flex-1 px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddTag}
|
|
className="px-6 bg-brand-500 text-white rounded-2xl font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all"
|
|
>
|
|
Hinzufügen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Kurz-Bio / Informationen</label>
|
|
<textarea
|
|
value={formState.bio}
|
|
onChange={e => setFormState({...formState, bio: e.target.value})}
|
|
rows={4}
|
|
placeholder="Wichtige Skills, Projekterfahrung oder Hintergrund..."
|
|
className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4 pt-4">
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-4 bg-slate-100 dark:bg-slate-800 text-slate-500 font-bold rounded-2xl hover:bg-slate-200 transition-all">Abbrechen</button>
|
|
<button type="submit" className="flex-[2] py-4 bg-brand-600 text-white font-bold rounded-2xl shadow-xl shadow-brand-500/20 hover:bg-brand-700 transition-all">Speichern</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{expertToDeleteId && (
|
|
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
|
<div className="bg-white dark:bg-slate-900 w-full max-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100 dark:border-red-900/30">
|
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 text-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
|
</div>
|
|
<h3 className="text-2xl font-bold mb-2 dark:text-white">Experten löschen?</h3>
|
|
<p className="text-slate-500 mb-8 text-sm">Möchten Sie diesen Experten wirklich unwiderruflich aus der Datenbank löschen?</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<button onClick={() => setExpertToDeleteId(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold text-slate-600 dark:text-slate-300">Abbruch</button>
|
|
<button onClick={confirmDeleteExpert} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20 hover:bg-red-700 transition-all">Löschen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|