Initial commit - ERP System
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft, Save, Building2, Mail, Key, Users, Plus, Trash2,
|
||||
Activity, FileText, FileBadge, Download, CheckCircle, Ticket,
|
||||
Phone, MapPin, Eye, EyeOff, RefreshCw, Copy
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { getStatusBadge } from '../../components/AppShell';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
type Tab = 'OVERVIEW' | 'TICKETS' | 'CONTACTS' | 'CONTRACTS' | 'MASTER_DATA' | 'CREDENTIALS' | 'DOCUMENTS' | 'SALES_DOCS';
|
||||
|
||||
export default function CustomerDashboard() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const customerId = params.id;
|
||||
const { toast, confirm } = useToast();
|
||||
const { data: session } = useSession();
|
||||
const perms = (session?.user as any)?.permissions || [];
|
||||
const canEdit = perms.includes('CUSTOMERS_MANAGE') || perms.includes('CUSTOMERS_EDIT');
|
||||
|
||||
const [customer, setCustomer] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('OVERVIEW');
|
||||
|
||||
// Credentials State
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [additionalEmails, setAdditionalEmails] = useState<string[]>([]);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Record<number, boolean>>({});
|
||||
const [newEmailInput, setNewEmailInput] = useState('');
|
||||
|
||||
// Modals
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [contactForm, setContactForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
|
||||
const [showCredentialModal, setShowCredentialModal] = useState(false);
|
||||
const [credentialForm, setCredentialForm] = useState({ title: '', username: '', password: '', description: '' });
|
||||
|
||||
const [showTicketModal, setShowTicketModal] = useState(false);
|
||||
const [ticketForm, setTicketForm] = useState({ title: '', description: '', priority: 'MEDIUM' });
|
||||
const [salesDocs, setSalesDocs] = useState<any[]>([]);
|
||||
|
||||
const generatePassword = (len = 16) => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*';
|
||||
return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomer();
|
||||
}, [customerId]);
|
||||
|
||||
const fetchCustomer = async () => {
|
||||
const res = await fetch(`/api/customers/${customerId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustomer(data);
|
||||
setFormData({
|
||||
companyName: data.companyName || '',
|
||||
firstName: data.firstName || '',
|
||||
lastName: data.lastName || '',
|
||||
email: data.email || '',
|
||||
phone: data.phone || '',
|
||||
address: data.address || '',
|
||||
zipCode: data.zipCode || '',
|
||||
city: data.city || ''
|
||||
});
|
||||
setAdditionalEmails(data.additionalEmails || []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customerId) fetch(`/api/sales?customerId=${customerId}`).then(r => r.json()).then(setSalesDocs).catch(() => {});
|
||||
}, [customerId]);
|
||||
|
||||
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch('/api/tickets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...ticketForm, customerId })
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowTicketModal(false);
|
||||
setTicketForm({ title: '', description: '', priority: 'MEDIUM' });
|
||||
fetchCustomer();
|
||||
toast('Ticket erstellt', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
// ── STAMMDATEN ──
|
||||
const handleSaveCustomer = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!canEdit) { toast('Keine Berechtigung', 'error'); return; }
|
||||
setSaving(true);
|
||||
const res = await fetch(`/api/customers/${customerId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...formData, additionalEmails })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast('Kundendaten erfolgreich gespeichert.', 'success');
|
||||
fetchCustomer();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast(data.error || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// ── CONTACTS ──
|
||||
const handleAddContact = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch(`/api/customers/${customerId}/contacts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(contactForm)
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowContactModal(false);
|
||||
setContactForm({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
fetchCustomer();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteContact = async (contactId: number) => {
|
||||
const isConfirmed = await confirm({ title: 'Mitarbeiter löschen', message: 'Mitarbeiter wirklich löschen?', danger: true });
|
||||
if (!isConfirmed) return;
|
||||
await fetch(`/api/customers/${customerId}/contacts?contactId=${contactId}`, { method: 'DELETE' });
|
||||
fetchCustomer();
|
||||
};
|
||||
|
||||
// ── CREDENTIALS ──
|
||||
const handleAddCredential = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch(`/api/customers/${customerId}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentialForm)
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowCredentialModal(false);
|
||||
setCredentialForm({ title: '', username: '', password: '', description: '' });
|
||||
fetchCustomer();
|
||||
toast('Zugangsdaten gespeichert', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCredential = async (credId: number) => {
|
||||
const isConfirmed = await confirm({ title: 'Löschen', message: 'Zugangsdaten wirklich löschen?', danger: true });
|
||||
if (!isConfirmed) return;
|
||||
await fetch(`/api/customers/${customerId}/credentials?credId=${credId}`, { method: 'DELETE' });
|
||||
fetchCustomer();
|
||||
};
|
||||
|
||||
// ── DOCUMENTS ──
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('file', file);
|
||||
|
||||
const res = await fetch(`/api/customers/${customerId}/documents`, {
|
||||
method: 'POST',
|
||||
body: uploadData
|
||||
});
|
||||
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
|
||||
if (res.ok) {
|
||||
toast('Dokument hochgeladen', 'success');
|
||||
fetchCustomer();
|
||||
} else {
|
||||
toast('Fehler beim Hochladen', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (docId: number) => {
|
||||
const isConfirmed = await confirm({ title: 'Dokument löschen', message: 'Dokument wirklich löschen?', danger: true });
|
||||
if (!isConfirmed) return;
|
||||
await fetch(`/api/customers/${customerId}/documents?docId=${docId}`, { method: 'DELETE' });
|
||||
fetchCustomer();
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-slate-500 font-medium">Lade Kundendaten...</div>;
|
||||
if (!customer) return <div className="p-8 text-red-600 font-medium">Kunde nicht gefunden.</div>;
|
||||
|
||||
const openTickets = customer.tickets?.filter((t:any) => t.status === 'OPEN' || t.status === 'IN_PROGRESS').length || 0;
|
||||
const totalTickets = customer.tickets?.length || 0;
|
||||
const activeContracts = customer.contracts?.filter((c:any) => c.status === 'ACTIVE').length || 0;
|
||||
|
||||
const tabs: { id: Tab, label: string, icon: any }[] = [
|
||||
{ id: 'OVERVIEW', label: 'Alles', icon: Activity },
|
||||
{ id: 'TICKETS', label: `Tickets (${totalTickets})`, icon: Ticket },
|
||||
{ id: 'CONTACTS', label: 'Mitarbeiter', icon: Users },
|
||||
{ id: 'CONTRACTS', label: 'Abos & Verträge', icon: FileBadge },
|
||||
{ id: 'SALES_DOCS', label: `Belege (${salesDocs.length})`, icon: FileText },
|
||||
{ id: 'MASTER_DATA', label: 'Stammdaten', icon: Building2 },
|
||||
{ id: 'CREDENTIALS', label: 'Zugangsdaten', icon: Key },
|
||||
{ id: 'DOCUMENTS', label: 'Dokumente', icon: FileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-4 pb-12 animate-fade-in-up">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<button onClick={() => router.push('/customers')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition mt-1">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
|
||||
</h1>
|
||||
{customer.companyName && <p className="text-sm text-slate-500 mt-0.5">{customer.firstName} {customer.lastName}</p>}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-2 text-sm text-slate-500">
|
||||
<span className="flex items-center gap-1.5"><Mail className="w-3.5 h-3.5" /> {customer.email}</span>
|
||||
{customer.phone && <span className="flex items-center gap-1.5"><Phone className="w-3.5 h-3.5" /> {customer.phone}</span>}
|
||||
{(customer.address || customer.city) && (
|
||||
<span className="flex items-center gap-1.5"><MapPin className="w-3.5 h-3.5" /> {[customer.address, customer.zipCode, customer.city].filter(Boolean).join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeTab === 'MASTER_DATA' && canEdit && (
|
||||
<button onClick={handleSaveCustomer} disabled={saving} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition disabled:opacity-50 flex items-center gap-2 text-sm">
|
||||
<Save className="w-4 h-4" /> {saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'TICKETS' && (
|
||||
<button onClick={() => setShowTicketModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Neues Ticket
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'CONTACTS' && canEdit && (
|
||||
<button onClick={() => setShowContactModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Mitarbeiter
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'CREDENTIALS' && canEdit && (
|
||||
<button onClick={() => setShowCredentialModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Zugangsdaten
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'DOCUMENTS' && (
|
||||
<div>
|
||||
<input type="file" className="hidden" ref={fileInputRef} onChange={handleFileUpload} />
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> {uploading ? 'Lädt...' : 'Dokument'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t border-slate-100">
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${openTickets > 0 ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{openTickets > 0 ? `${openTickets} offene Tickets` : 'Keine offenen Tickets'}
|
||||
</span>
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
|
||||
{totalTickets} Tickets gesamt
|
||||
</span>
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-indigo-50 text-indigo-600">
|
||||
{activeContracts} aktive Verträge
|
||||
</span>
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
|
||||
{customer.contacts?.length || 0} Mitarbeiter
|
||||
</span>
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
|
||||
{customer.credentials?.length || 0} Zugangsdaten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex overflow-x-auto border-b border-slate-200 gap-6 no-scrollbar">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors relative whitespace-nowrap
|
||||
${isActive ? 'text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
{isActive && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600 animate-in fade-in duration-300"></div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
|
||||
{/* ── OVERVIEW ── */}
|
||||
{activeTab === 'OVERVIEW' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in-up">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-indigo-600" /> Letzte Aktivitäten
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{customer.tickets?.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 text-center py-4">Bisher keine Tickets vorhanden.</p>
|
||||
) : (
|
||||
customer.tickets?.slice(0, 5).map((t: any) => (
|
||||
<div key={t.id} onClick={() => router.push(`/tickets/${t.id}`)} className="flex items-start justify-between p-4 bg-slate-50 hover:bg-indigo-50/50 rounded-xl border border-slate-100 cursor-pointer transition-colors group">
|
||||
<div className="flex gap-4">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-slate-200 group-hover:border-indigo-200">
|
||||
<Ticket className="w-5 h-5 text-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800 text-sm">{t.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-1">{t.description}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1 font-mono">{new Date(t.createdAt).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{getStatusBadge(t.status)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-center gap-4 hover-lift">
|
||||
<div className="p-3 bg-amber-50 text-amber-600 rounded-xl">
|
||||
<Ticket className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Tickets gesamt</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{customer.tickets?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-center gap-4 hover-lift">
|
||||
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
|
||||
<FileBadge className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Aktive Verträge</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{customer.contracts?.filter((c:any) => c.status === 'ACTIVE').length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TICKETS ── */}
|
||||
{activeTab === 'TICKETS' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
|
||||
{customer.tickets?.length === 0 ? (
|
||||
<div className="p-8 text-slate-500 text-sm text-center">Keine Tickets vorhanden.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4">ID</th>
|
||||
<th className="px-6 py-4">Titel</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4">Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{customer.tickets.map((t: any) => (
|
||||
<tr key={t.id} onClick={() => router.push(`/tickets/${t.id}`)} className="hover:bg-indigo-50/40 transition-colors cursor-pointer">
|
||||
<td className="px-6 py-4 font-mono text-slate-400">#{t.id.toString().padStart(4,'0')}</td>
|
||||
<td className="px-6 py-4 font-medium text-slate-900">{t.title}</td>
|
||||
<td className="px-6 py-4">{getStatusBadge(t.status)}</td>
|
||||
<td className="px-6 py-4 text-slate-500">{new Date(t.createdAt).toLocaleDateString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CONTACTS ── */}
|
||||
{activeTab === 'CONTACTS' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
|
||||
{customer.contacts?.length === 0 ? (
|
||||
<div className="p-8 text-slate-500 text-sm text-center">Keine Mitarbeiter hinterlegt.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Name</th>
|
||||
<th className="px-6 py-4">E-Mail</th>
|
||||
<th className="px-6 py-4">Telefon</th>
|
||||
<th className="px-6 py-4 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{customer.contacts.map((contact: any) => (
|
||||
<tr key={contact.id} className="hover:bg-slate-50/80 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-900">{contact.firstName} {contact.lastName}</td>
|
||||
<td className="px-6 py-4 text-slate-600">{contact.email}</td>
|
||||
<td className="px-6 py-4 text-slate-600">{contact.phone || '-'}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => handleDeleteContact(contact.id)} className="text-slate-400 hover:text-red-600 transition p-1">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CONTRACTS ── */}
|
||||
{activeTab === 'CONTRACTS' && (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
{customer.contracts?.length === 0 ? (
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200 text-center text-slate-500">
|
||||
Keine Verträge vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{customer.contracts?.map((contract: any) => (
|
||||
<div key={contract.id} className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 hover-lift group relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`p-2.5 rounded-xl ${contract.status === 'ACTIVE' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
<FileBadge className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900">{contract.title}</h3>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${contract.status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'}`}>
|
||||
{contract.status === 'ACTIVE' ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{contract.description && <p className="text-sm text-slate-600 mb-4 line-clamp-2">{contract.description}</p>}
|
||||
<div className="flex items-end justify-between pt-4 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Gültig ab</p>
|
||||
<p className="text-sm font-semibold text-slate-700">{new Date(contract.startDate).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">Preis / Monat</p>
|
||||
<p className="text-lg font-bold text-indigo-600">{contract.monthlyPrice.toFixed(2)} €</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── MASTER DATA ── */}
|
||||
{activeTab === 'MASTER_DATA' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-up">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 space-y-4">
|
||||
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-indigo-600" /> Stammdaten
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Firmenname</label>
|
||||
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.companyName} onChange={e => setFormData({...formData, companyName: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
|
||||
<input type="text" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.firstName} onChange={e => setFormData({...formData, firstName: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
|
||||
<input type="text" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.lastName} onChange={e => setFormData({...formData, lastName: e.target.value})} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Straße & Hausnummer</label>
|
||||
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">PLZ</label>
|
||||
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.zipCode} onChange={e => setFormData({...formData, zipCode: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Stadt</label>
|
||||
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.city} onChange={e => setFormData({...formData, city: e.target.value})} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Telefon</label>
|
||||
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-indigo-600" /> Portal-Zugang
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Haupt-Login (E-Mail) *</label>
|
||||
<input type="email" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all bg-slate-50 text-slate-600 disabled:opacity-60" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-indigo-600" /> Ticket E-Mails
|
||||
</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input type="email" placeholder="E-Mail hinzufügen..." className="flex-1 border border-slate-300 p-2 rounded-xl text-sm outline-none focus-ring" value={newEmailInput} onChange={e => setNewEmailInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && setAdditionalEmails([...additionalEmails, newEmailInput])} />
|
||||
<button onClick={() => { setAdditionalEmails([...additionalEmails, newEmailInput]); setNewEmailInput(''); }} type="button" className="bg-slate-100 text-slate-700 px-3 py-2 rounded-xl text-sm font-medium hover:bg-slate-200 transition">Add</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{additionalEmails.map((email, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 border border-slate-100 px-3 py-2 rounded-xl group">
|
||||
<span className="text-sm text-slate-700">{email}</span>
|
||||
<button type="button" onClick={() => setAdditionalEmails(additionalEmails.filter(e => e !== email))} className="text-slate-400 hover:text-red-600 transition opacity-0 group-hover:opacity-100">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CREDENTIALS ── */}
|
||||
{activeTab === 'CREDENTIALS' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
|
||||
{customer.credentials?.length === 0 ? (
|
||||
<div className="p-8 text-slate-500 text-sm text-center">Keine Zugangsdaten hinterlegt.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Bezeichnung</th>
|
||||
<th className="px-6 py-4">Benutzername</th>
|
||||
<th className="px-6 py-4">Passwort</th>
|
||||
<th className="px-6 py-4 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{customer.credentials.map((cred: any) => (
|
||||
<tr key={cred.id} className="hover:bg-slate-50/80 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-900">{cred.title}</td>
|
||||
<td className="px-6 py-4 text-slate-600">
|
||||
<button onClick={() => { navigator.clipboard.writeText(cred.username); toast('Kopiert', 'success'); }} className="hover:text-indigo-600 transition flex items-center gap-1.5" title="Kopieren">
|
||||
{cred.username} <Copy className="w-3 h-3 opacity-40" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600 font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{visiblePasswords[cred.id] ? cred.password : '••••••••'}</span>
|
||||
<button onClick={() => setVisiblePasswords(p => ({...p, [cred.id]: !p[cred.id]}))} className="text-slate-400 hover:text-indigo-600 transition" title="Anzeigen/Verbergen">
|
||||
{visiblePasswords[cred.id] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => { navigator.clipboard.writeText(cred.password); toast('Passwort kopiert', 'success'); }} className="text-slate-400 hover:text-indigo-600 transition" title="Kopieren">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => handleDeleteCredential(cred.id)} className="text-slate-400 hover:text-red-600 transition p-1">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── DOCUMENTS ── */}
|
||||
{activeTab === 'DOCUMENTS' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
|
||||
{customer.documents?.length === 0 ? (
|
||||
<div className="p-8 text-slate-500 text-sm text-center">Keine Dokumente hinterlegt.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Dateiname</th>
|
||||
<th className="px-6 py-4">Größe</th>
|
||||
<th className="px-6 py-4">Hochgeladen</th>
|
||||
<th className="px-6 py-4 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{customer.documents.map((doc: any) => (
|
||||
<tr key={doc.id} className="hover:bg-slate-50/80 transition-colors group">
|
||||
<td className="px-6 py-4 font-medium text-slate-900 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-400" />
|
||||
{doc.fileName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600">{formatBytes(doc.fileSize)}</td>
|
||||
<td className="px-6 py-4 text-slate-600">{new Date(doc.createdAt).toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-6 py-4 text-right flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a href={`/api/customers/${customerId}/documents?download=${doc.id}`} download className="p-1.5 text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
<button onClick={() => handleDeleteDocument(doc.id)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── SALES DOCS ── */}
|
||||
{activeTab === 'SALES_DOCS' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
|
||||
{salesDocs.length === 0 ? (
|
||||
<div className="p-8 text-slate-500 text-sm text-center">Keine Belege vorhanden.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Nummer</th>
|
||||
<th className="px-6 py-4">Typ</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4 text-right">Betrag</th>
|
||||
<th className="px-6 py-4">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{salesDocs.map((d: any) => (
|
||||
<tr key={d.id} onClick={() => router.push(`/sales/${d.id}`)} className="hover:bg-indigo-50/40 transition-colors cursor-pointer">
|
||||
<td className="px-6 py-4 font-mono font-semibold text-slate-900">{d.number}</td>
|
||||
<td className="px-6 py-4"><span className="px-2 py-0.5 rounded-md text-xs font-semibold bg-slate-100 text-slate-600">{{'QUOTE':'Angebot','ORDER_CONFIRMATION':'AB','DELIVERY_NOTE':'Lieferschein','INVOICE':'Rechnung','CREDIT_NOTE':'RK'}[d.type as string]}</span></td>
|
||||
<td className="px-6 py-4"><span className="px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600">{{'DRAFT':'Entwurf','SENT':'Gesendet','ACCEPTED':'Angenommen','REJECTED':'Abgelehnt','DELIVERED':'Geliefert','PAID':'Bezahlt','CANCELLED':'Storniert','ARCHIVED':'Archiviert'}[d.status as string]}</span></td>
|
||||
<td className="px-6 py-4 text-right font-semibold">{d.total.toFixed(2)} €</td>
|
||||
<td className="px-6 py-4 text-slate-500">{new Date(d.createdAt).toLocaleDateString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MODAL: Mitarbeiter */}
|
||||
{showContactModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
|
||||
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">Mitarbeiter hinzufügen</h2>
|
||||
<form onSubmit={handleAddContact} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
|
||||
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.firstName} onChange={e => setContactForm({...contactForm, firstName: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
|
||||
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.lastName} onChange={e => setContactForm({...contactForm, lastName: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
|
||||
<input type="email" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.email} onChange={e => setContactForm({...contactForm, email: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Telefon</label>
|
||||
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.phone} onChange={e => setContactForm({...contactForm, phone: e.target.value})} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<button type="button" onClick={() => setShowContactModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
|
||||
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: Zugangsdaten */}
|
||||
{showCredentialModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
|
||||
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">Zugangsdaten anlegen</h2>
|
||||
<form onSubmit={handleAddCredential} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Bezeichnung *</label>
|
||||
<input type="text" required placeholder="z.B. Microsoft 365 Admin" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.title} onChange={e => setCredentialForm({...credentialForm, title: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername *</label>
|
||||
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.username} onChange={e => setCredentialForm({...credentialForm, username: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input type="text" placeholder="Optional" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.description} onChange={e => setCredentialForm({...credentialForm, description: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort *</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" required className="flex-1 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all font-mono" value={credentialForm.password} onChange={e => setCredentialForm({...credentialForm, password: e.target.value})} />
|
||||
<button type="button" onClick={() => setCredentialForm({...credentialForm, password: generatePassword()})} className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-2 rounded-xl transition flex items-center gap-1.5 text-sm font-medium flex-shrink-0" title="Passwort generieren">
|
||||
<RefreshCw className="w-4 h-4" /> Generieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<button type="button" onClick={() => setShowCredentialModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
|
||||
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: Neues Ticket */}
|
||||
{showTicketModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
|
||||
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">Neues Ticket erstellen</h2>
|
||||
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
|
||||
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={ticketForm.title} onChange={e => setTicketForm({...ticketForm, title: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
|
||||
<textarea required rows={3} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all resize-none" value={ticketForm.description} onChange={e => setTicketForm({...ticketForm, description: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Priorität</label>
|
||||
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={ticketForm.priority} onChange={e => setTicketForm({...ticketForm, priority: e.target.value})}>
|
||||
<option value="LOW">Niedrig</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
<option value="CRITICAL">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<button type="button" onClick={() => setShowTicketModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
|
||||
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Ticket erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user