first commit
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { customerApi, contractApi, contractTaskApi, appSettingsApi } from '../services/api';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Input from '../components/ui/Input';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
User,
|
||||
ClipboardList,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type { Contract } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, isCustomer, isCustomerPortal } = useAuth();
|
||||
const [showCreateTicketModal, setShowCreateTicketModal] = useState(false);
|
||||
|
||||
// Lade öffentliche Einstellungen (für Kundenportal - Support-Tickets aktiviert?)
|
||||
const { data: publicSettings, isLoading: isLoadingSettings } = useQuery({
|
||||
queryKey: ['app-settings-public'],
|
||||
queryFn: () => appSettingsApi.getPublic(),
|
||||
enabled: isCustomerPortal,
|
||||
staleTime: 0, // Immer neu laden, damit Einstellungsänderungen sofort wirken
|
||||
});
|
||||
|
||||
// Wichtig: Nur true wenn explizit aktiviert UND geladen
|
||||
const supportTicketsEnabled = !isLoadingSettings && publicSettings?.data?.customerSupportTicketsEnabled === 'true';
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-count'],
|
||||
queryFn: () => customerApi.getAll({ limit: 1 }),
|
||||
enabled: !isCustomer,
|
||||
});
|
||||
|
||||
const { data: contractsData } = useQuery({
|
||||
queryKey: ['contracts', isCustomer ? user?.customerId : undefined],
|
||||
queryFn: () => contractApi.getAll(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
|
||||
});
|
||||
|
||||
const { data: activeContractsData } = useQuery({
|
||||
queryKey: ['contracts-active', isCustomer ? user?.customerId : undefined],
|
||||
queryFn: () => contractApi.getAll({
|
||||
status: 'ACTIVE',
|
||||
...(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: pendingContractsData } = useQuery({
|
||||
queryKey: ['contracts-pending', isCustomer ? user?.customerId : undefined],
|
||||
queryFn: () => contractApi.getAll({
|
||||
status: 'PENDING',
|
||||
...(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Task-Statistik
|
||||
const { data: taskStatsData } = useQuery({
|
||||
queryKey: ['task-stats'],
|
||||
queryFn: () => contractTaskApi.getStats(),
|
||||
});
|
||||
|
||||
// Vertrags-Cockpit für Mitarbeiter/Admins
|
||||
const { data: cockpitData } = useQuery({
|
||||
queryKey: ['contract-cockpit'],
|
||||
queryFn: () => contractApi.getCockpit(),
|
||||
enabled: !isCustomer,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Für Kundenportal: Verträge nach eigene/fremd gruppieren
|
||||
const { ownContracts, representedContracts } = useMemo(() => {
|
||||
if (!isCustomerPortal || !contractsData?.data) {
|
||||
return { ownContracts: [], representedContracts: [] };
|
||||
}
|
||||
|
||||
const own: Contract[] = [];
|
||||
const represented: Record<number, { customerName: string; contracts: Contract[] }> = {};
|
||||
|
||||
for (const contract of contractsData.data) {
|
||||
if (contract.customerId === user?.customerId) {
|
||||
own.push(contract);
|
||||
} else {
|
||||
const customerId = contract.customerId;
|
||||
if (!represented[customerId]) {
|
||||
const customerName = contract.customer
|
||||
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
|
||||
: `Kunde ${customerId}`;
|
||||
represented[customerId] = { customerName, contracts: [] };
|
||||
}
|
||||
represented[customerId].contracts.push(contract);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ownContracts: own,
|
||||
representedContracts: Object.values(represented).sort((a, b) =>
|
||||
a.customerName.localeCompare(b.customerName)
|
||||
),
|
||||
};
|
||||
}, [contractsData?.data, isCustomerPortal, user?.customerId]);
|
||||
|
||||
// Zähle Verträge für eigene vs. fremd
|
||||
const ownActiveCount = useMemo(() =>
|
||||
ownContracts.filter(c => c.status === 'ACTIVE').length,
|
||||
[ownContracts]
|
||||
);
|
||||
const ownPendingCount = useMemo(() =>
|
||||
ownContracts.filter(c => c.status === 'PENDING').length,
|
||||
[ownContracts]
|
||||
);
|
||||
const ownExpiredCount = useMemo(() =>
|
||||
ownContracts.filter(c => c.status === 'EXPIRED').length,
|
||||
[ownContracts]
|
||||
);
|
||||
const representedTotalCount = useMemo(() =>
|
||||
representedContracts.reduce((sum, g) => sum + g.contracts.length, 0),
|
||||
[representedContracts]
|
||||
);
|
||||
const representedActiveCount = useMemo(() =>
|
||||
representedContracts.reduce((sum, g) => sum + g.contracts.filter(c => c.status === 'ACTIVE').length, 0),
|
||||
[representedContracts]
|
||||
);
|
||||
const representedExpiredCount = useMemo(() =>
|
||||
representedContracts.reduce((sum, g) => sum + g.contracts.filter(c => c.status === 'EXPIRED').length, 0),
|
||||
[representedContracts]
|
||||
);
|
||||
|
||||
const openTasksCount = taskStatsData?.data?.openCount || 0;
|
||||
|
||||
// Helper zum Rendern einer klickbaren Stat-Karte
|
||||
const renderStatCard = (stat: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof FileText;
|
||||
color: string;
|
||||
link?: string;
|
||||
}) => (
|
||||
<Card key={stat.label} className={stat.link ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}>
|
||||
{stat.link ? (
|
||||
<Link to={stat.link} className="block">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${stat.color}`}>
|
||||
<stat.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${stat.color}`}>
|
||||
<stat.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Willkommen, {user?.firstName}!
|
||||
</h1>
|
||||
{/* Support-Ticket erstellen Button für Kundenportal */}
|
||||
{isCustomerPortal && supportTicketsEnabled && (
|
||||
<Button onClick={() => setShowCreateTicketModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Support-Anfrage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kundenportal: Getrennte Statistiken */}
|
||||
{isCustomerPortal ? (
|
||||
<>
|
||||
{/* Eigene Verträge Stats */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold">Meine Verträge</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{renderStatCard({
|
||||
label: 'Eigene Verträge',
|
||||
value: ownContracts.length,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
link: '/contracts',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Davon aktiv',
|
||||
value: ownActiveCount,
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-500',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Davon ausstehend',
|
||||
value: ownPendingCount,
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-500',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Davon abgelaufen',
|
||||
value: ownExpiredCount,
|
||||
icon: XCircle,
|
||||
color: 'bg-red-500',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fremdverträge Stats - nur anzeigen wenn vorhanden */}
|
||||
{representedTotalCount > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
<h2 className="text-lg font-semibold">Fremdverträge</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{renderStatCard({
|
||||
label: 'Fremdverträge',
|
||||
value: representedTotalCount,
|
||||
icon: Users,
|
||||
color: 'bg-purple-500',
|
||||
link: '/contracts',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Davon aktiv',
|
||||
value: representedActiveCount,
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-500',
|
||||
})}
|
||||
{/* Leere Karte für Symmetrie */}
|
||||
<div className="hidden lg:block"></div>
|
||||
{renderStatCard({
|
||||
label: 'Davon abgelaufen',
|
||||
value: representedExpiredCount,
|
||||
icon: XCircle,
|
||||
color: 'bg-red-500',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Support-Anfragen Stats */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="w-5 h-5 text-orange-600" />
|
||||
<h2 className="text-lg font-semibold">Support-Anfragen</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{renderStatCard({
|
||||
label: 'Offene Anfragen',
|
||||
value: openTasksCount,
|
||||
icon: MessageSquare,
|
||||
color: 'bg-orange-500',
|
||||
link: '/tasks',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Mitarbeiter/Admin: Standard Stats */
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{renderStatCard({
|
||||
label: 'Kunden',
|
||||
value: customersData?.pagination?.total || 0,
|
||||
icon: Users,
|
||||
color: 'bg-blue-500',
|
||||
link: '/customers',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Verträge gesamt',
|
||||
value: contractsData?.pagination?.total || 0,
|
||||
icon: FileText,
|
||||
color: 'bg-purple-500',
|
||||
link: '/contracts',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Aktive Verträge',
|
||||
value: activeContractsData?.pagination?.total || 0,
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-500',
|
||||
})}
|
||||
{renderStatCard({
|
||||
label: 'Ausstehende Verträge',
|
||||
value: pendingContractsData?.pagination?.total || 0,
|
||||
icon: AlertCircle,
|
||||
color: 'bg-yellow-500',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vertrags-Cockpit Übersicht */}
|
||||
{cockpitData?.data && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<h2 className="text-lg font-semibold">Vertrags-Cockpit</h2>
|
||||
</div>
|
||||
<Link to="/contracts/cockpit" className="text-sm text-blue-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<Link to="/contracts/cockpit?filter=critical" className="block">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-lg bg-red-100">
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">Kritisch (<{cockpitData.data.thresholds.criticalDays} Tage)</p>
|
||||
<p className="text-2xl font-bold text-red-600">{cockpitData.data.summary.criticalCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<Link to="/contracts/cockpit?filter=warning" className="block">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-lg bg-yellow-100">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">Warnung (<{cockpitData.data.thresholds.warningDays} Tage)</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{cockpitData.data.summary.warningCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<Link to="/contracts/cockpit?filter=ok" className="block">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-lg bg-green-100">
|
||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">OK (<{cockpitData.data.thresholds.okDays} Tage)</p>
|
||||
<p className="text-2xl font-bold text-green-600">{cockpitData.data.summary.okCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<Link to="/contracts/cockpit" className="block">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-lg bg-gray-100">
|
||||
<FileText className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-500">Handlungsbedarf</p>
|
||||
<p className="text-2xl font-bold text-gray-600">{cockpitData.data.summary.totalContracts}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aufgaben Stats für Mitarbeiter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ClipboardList className="w-5 h-5 text-orange-600" />
|
||||
<h2 className="text-lg font-semibold">Aufgaben</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{renderStatCard({
|
||||
label: 'Offene Aufgaben',
|
||||
value: openTasksCount,
|
||||
icon: ClipboardList,
|
||||
color: 'bg-orange-500',
|
||||
link: '/tasks',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Support-Ticket erstellen Modal (für Kundenportal) */}
|
||||
{isCustomerPortal && (
|
||||
<CreateSupportTicketModal
|
||||
isOpen={showCreateTicketModal}
|
||||
onClose={() => setShowCreateTicketModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal für neue Support-Anfrage (Kundenportal)
|
||||
function CreateSupportTicketModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [customerFilter, setCustomerFilter] = useState<'own' | number>('own');
|
||||
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [contractSearch, setContractSearch] = useState('');
|
||||
|
||||
// Lade alle Verträge des Benutzers (eigene + freigegebene)
|
||||
const { data: contractsData } = useQuery({
|
||||
queryKey: ['contracts', user?.customerId],
|
||||
queryFn: () => contractApi.getAll({ customerId: user?.customerId }),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Gruppiere Verträge nach Kunde
|
||||
const groupedContracts = useMemo(() => {
|
||||
if (!contractsData?.data) return { own: [], represented: {} as Record<number, { name: string; contracts: Contract[] }> };
|
||||
|
||||
const own: Contract[] = [];
|
||||
const represented: Record<number, { name: string; contracts: Contract[] }> = {};
|
||||
|
||||
for (const contract of contractsData.data) {
|
||||
if (contract.customerId === user?.customerId) {
|
||||
own.push(contract);
|
||||
} else {
|
||||
if (!represented[contract.customerId]) {
|
||||
const name = contract.customer
|
||||
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
|
||||
: `Kunde ${contract.customerId}`;
|
||||
represented[contract.customerId] = { name, contracts: [] };
|
||||
}
|
||||
represented[contract.customerId].contracts.push(contract);
|
||||
}
|
||||
}
|
||||
|
||||
return { own, represented };
|
||||
}, [contractsData?.data, user?.customerId]);
|
||||
|
||||
// Hat der Benutzer freigegebene Kunden?
|
||||
const hasRepresentedCustomers = Object.keys(groupedContracts.represented).length > 0;
|
||||
|
||||
// Aktuelle Verträge basierend auf Kundenfilter
|
||||
const currentContracts = useMemo(() => {
|
||||
if (customerFilter === 'own') {
|
||||
return groupedContracts.own;
|
||||
}
|
||||
return groupedContracts.represented[customerFilter]?.contracts || [];
|
||||
}, [customerFilter, groupedContracts]);
|
||||
|
||||
// Gefilterte Verträge basierend auf Suche
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!contractSearch) return currentContracts;
|
||||
const search = contractSearch.toLowerCase();
|
||||
return currentContracts.filter(c =>
|
||||
c.contractNumber.toLowerCase().includes(search) ||
|
||||
(c.providerName || '').toLowerCase().includes(search) ||
|
||||
(c.tariffName || '').toLowerCase().includes(search)
|
||||
);
|
||||
}, [currentContracts, contractSearch]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedContractId || !title.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await contractTaskApi.createSupportTicket(selectedContractId, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
// Invalidate task stats
|
||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
onClose();
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSelectedContractId(null);
|
||||
setCustomerFilter('own');
|
||||
// Navigate to the contract
|
||||
navigate(`/contracts/${selectedContractId}`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Support-Anfrage:', error);
|
||||
alert('Fehler beim Erstellen der Support-Anfrage. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSelectedContractId(null);
|
||||
setCustomerFilter('own');
|
||||
setContractSearch('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Neue Support-Anfrage"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Kundenauswahl (nur wenn freigegebene Kunden vorhanden) */}
|
||||
{hasRepresentedCustomers && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
value={customerFilter}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setCustomerFilter(val === 'own' ? 'own' : parseInt(val));
|
||||
setSelectedContractId(null);
|
||||
setContractSearch('');
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="own">Eigene Verträge</option>
|
||||
{Object.entries(groupedContracts.represented).map(([id, { name }]) => (
|
||||
<option key={id} value={id}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vertragsauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vertrag *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Vertrag suchen..."
|
||||
value={contractSearch}
|
||||
onChange={(e) => setContractSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg">
|
||||
{filteredContracts.length > 0 ? (
|
||||
filteredContracts.map((contract) => (
|
||||
<div
|
||||
key={contract.id}
|
||||
onClick={() => setSelectedContractId(contract.id)}
|
||||
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
|
||||
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{contract.contractNumber}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{contract.providerName || 'Kein Anbieter'}
|
||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-gray-500 text-center">
|
||||
Keine Verträge gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Titel */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel *
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Kurze Beschreibung Ihres Anliegens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Detaillierte Beschreibung (optional)"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedContractId || !title.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Wird erstellt...' : 'Anfrage erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Button from '../components/ui/Button';
|
||||
import Input from '../components/ui/Input';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, customerLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(''); // Fehler nur beim Klick auf Login löschen
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Erst Mitarbeiter-Login versuchen
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
return;
|
||||
} catch {
|
||||
// Mitarbeiter-Login fehlgeschlagen, versuche Kunden-Login
|
||||
}
|
||||
|
||||
try {
|
||||
await customerLogin(email, password);
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Beide fehlgeschlagen
|
||||
setError('Ungültige Anmeldedaten');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">OpenCRM</h1>
|
||||
<p className="text-gray-600 mt-2">Melden Sie sich an</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
to: '/settings/users',
|
||||
icon: UserCog,
|
||||
title: 'Benutzer',
|
||||
description: 'Verwalten Sie Benutzerkonten, Rollen und Berechtigungen.',
|
||||
show: hasPermission('users:read'),
|
||||
},
|
||||
{
|
||||
to: '/settings/platforms',
|
||||
icon: Store,
|
||||
title: 'Vertriebsplattformen',
|
||||
description: 'Verwalten Sie die Plattformen, über die Verträge abgeschlossen werden.',
|
||||
show: hasPermission('platforms:read'),
|
||||
},
|
||||
{
|
||||
to: '/settings/cancellation-periods',
|
||||
icon: Clock,
|
||||
title: 'Kündigungsfristen',
|
||||
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
|
||||
show: hasPermission('platforms:read'),
|
||||
},
|
||||
{
|
||||
to: '/settings/contract-durations',
|
||||
icon: Calendar,
|
||||
title: 'Vertragslaufzeiten',
|
||||
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
|
||||
show: hasPermission('platforms:read'),
|
||||
},
|
||||
{
|
||||
to: '/settings/providers',
|
||||
icon: Building2,
|
||||
title: 'Anbieter & Tarife',
|
||||
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
|
||||
show: hasPermission('providers:read') || hasPermission('platforms:read'),
|
||||
},
|
||||
{
|
||||
to: '/settings/contract-categories',
|
||||
icon: FileType,
|
||||
title: 'Vertragstypen',
|
||||
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
||||
show: hasPermission('platforms:read'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<SettingsIcon className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Einstellungen</h1>
|
||||
</div>
|
||||
|
||||
{/* Stammdaten-Konfiguration */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-700">Stammdaten</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{settingsCards
|
||||
.filter((card) => card.show)
|
||||
.map((card) => (
|
||||
<Link
|
||||
key={card.to}
|
||||
to={card.to}
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<card.icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
{card.title}
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{card.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System-Einstellungen */}
|
||||
{hasPermission('settings:update') && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-700">System</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/settings/portal"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Globe className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Kundenportal
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie das Kundenportal und Support-Anfragen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings/deadlines"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Clock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Fristenschwellen
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die Farbkodierung für Vertragsfristen im Cockpit.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings/email-providers"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Mail className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Email-Provisionierung
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Stressfrei-Wechseln Adressen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persönliche Einstellungen */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-700">Persönlich</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/settings/view"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Eye className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Ansicht
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Passen Sie die Darstellung der Anwendung an.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPermission('developer:access') && (
|
||||
<Card title="Entwickleroptionen" className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Entwicklermodus</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Aktiviert erweiterte Funktionen wie direkten Datenbankzugriff
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={developerMode}
|
||||
onChange={(e) => setDeveloperMode(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
{developerMode && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Warnung:</strong> Der Entwicklermodus ermöglicht direkten Zugriff auf die Datenbank.
|
||||
Unsachgemäße Änderungen können zu Datenverlust oder Inkonsistenzen führen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Über">
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Version</dt>
|
||||
<dd>1.0.0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">System</dt>
|
||||
<dd>OpenCRM</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { contractApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Select from '../../components/ui/Select';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Calendar,
|
||||
Key,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
Wifi,
|
||||
Smartphone,
|
||||
Tv,
|
||||
Car,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
|
||||
|
||||
const typeIcons: Record<ContractType, typeof Zap> = {
|
||||
ELECTRICITY: Zap,
|
||||
GAS: Flame,
|
||||
DSL: Wifi,
|
||||
CABLE: Wifi,
|
||||
FIBER: Wifi,
|
||||
MOBILE: Smartphone,
|
||||
TV: Tv,
|
||||
CAR_INSURANCE: Car,
|
||||
};
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
ELECTRICITY: 'Strom',
|
||||
GAS: 'Gas',
|
||||
DSL: 'DSL',
|
||||
CABLE: 'Kabel',
|
||||
FIBER: 'Glasfaser',
|
||||
MOBILE: 'Mobilfunk',
|
||||
TV: 'TV',
|
||||
CAR_INSURANCE: 'KFZ',
|
||||
};
|
||||
|
||||
const urgencyColors: Record<CockpitUrgencyLevel, string> = {
|
||||
critical: 'bg-red-100 border-red-300 text-red-800',
|
||||
warning: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
ok: 'bg-green-100 border-green-300 text-green-800',
|
||||
none: 'bg-gray-100 border-gray-300 text-gray-800',
|
||||
};
|
||||
|
||||
const urgencyBadgeVariants: Record<CockpitUrgencyLevel, 'danger' | 'warning' | 'success' | 'default'> = {
|
||||
critical: 'danger',
|
||||
warning: 'warning',
|
||||
ok: 'success',
|
||||
none: 'default',
|
||||
};
|
||||
|
||||
const issueTypeIcons: Record<string, typeof Calendar> = {
|
||||
cancellation_deadline: Calendar,
|
||||
contract_ending: Clock,
|
||||
missing_cancellation_letter: FileText,
|
||||
missing_cancellation_confirmation: FileText,
|
||||
missing_portal_credentials: Key,
|
||||
missing_customer_number: FileText,
|
||||
missing_provider: FileText,
|
||||
missing_address: FileText,
|
||||
missing_bank: FileText,
|
||||
missing_meter: Zap,
|
||||
missing_sim: Smartphone,
|
||||
open_tasks: ClipboardList,
|
||||
pending_status: Clock,
|
||||
draft_status: FileText,
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
cancellationDeadlines: 'Kündigungsfristen',
|
||||
contractEnding: 'Vertragsenden',
|
||||
missingCredentials: 'Fehlende Zugangsdaten',
|
||||
missingData: 'Fehlende Daten',
|
||||
openTasks: 'Offene Aufgaben',
|
||||
pendingContracts: 'Wartende Verträge',
|
||||
};
|
||||
|
||||
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks';
|
||||
|
||||
export default function ContractCockpit() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
||||
|
||||
// Filter aus URL-Parameter initialisieren
|
||||
const urlFilter = searchParams.get('filter') as FilterType | null;
|
||||
const [filter, setFilter] = useState<FilterType>(urlFilter || 'all');
|
||||
|
||||
// URL-Parameter bei Filter-Änderung aktualisieren
|
||||
useEffect(() => {
|
||||
if (filter === 'all') {
|
||||
searchParams.delete('filter');
|
||||
} else {
|
||||
searchParams.set('filter', filter);
|
||||
}
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [filter, searchParams, setSearchParams]);
|
||||
|
||||
const { data: cockpitData, isLoading, error } = useQuery({
|
||||
queryKey: ['contract-cockpit'],
|
||||
queryFn: () => contractApi.getCockpit(),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const toggleExpanded = (contractId: number) => {
|
||||
setExpandedContracts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(contractId)) {
|
||||
next.delete(contractId);
|
||||
} else {
|
||||
next.add(contractId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Filter contracts
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!cockpitData?.data?.contracts) return [];
|
||||
|
||||
const contracts = cockpitData.data.contracts;
|
||||
|
||||
switch (filter) {
|
||||
case 'critical':
|
||||
return contracts.filter(c => c.highestUrgency === 'critical');
|
||||
case 'warning':
|
||||
return contracts.filter(c => c.highestUrgency === 'warning');
|
||||
case 'ok':
|
||||
return contracts.filter(c => c.highestUrgency === 'ok');
|
||||
case 'deadlines':
|
||||
return contracts.filter(c =>
|
||||
c.issues.some(i => ['cancellation_deadline', 'contract_ending'].includes(i.type))
|
||||
);
|
||||
case 'credentials':
|
||||
return contracts.filter(c =>
|
||||
c.issues.some(i => i.type.includes('credentials'))
|
||||
);
|
||||
case 'data':
|
||||
return contracts.filter(c =>
|
||||
c.issues.some(i => i.type.startsWith('missing_') && !i.type.includes('credentials'))
|
||||
);
|
||||
case 'tasks':
|
||||
return contracts.filter(c =>
|
||||
c.issues.some(i => ['open_tasks', 'pending_status', 'draft_status'].includes(i.type))
|
||||
);
|
||||
default:
|
||||
return contracts;
|
||||
}
|
||||
}, [cockpitData?.data?.contracts, filter]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cockpitData?.data) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">Fehler beim Laden des Cockpits</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, thresholds } = cockpitData.data;
|
||||
|
||||
const renderContract = (contract: CockpitContract) => {
|
||||
const isExpanded = expandedContracts.has(contract.id);
|
||||
const TypeIcon = typeIcons[contract.type] || FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={contract.id}
|
||||
className={`border rounded-lg mb-2 ${urgencyColors[contract.highestUrgency]}`}
|
||||
>
|
||||
{/* Contract Header */}
|
||||
<div
|
||||
className="flex items-center p-4 cursor-pointer hover:bg-opacity-50"
|
||||
onClick={() => toggleExpanded(contract.id)}
|
||||
>
|
||||
{/* Expand Icon */}
|
||||
<div className="w-6 mr-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type Icon */}
|
||||
<TypeIcon className="w-5 h-5 mr-3" />
|
||||
|
||||
{/* Contract Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/contracts/${contract.id}`}
|
||||
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
|
||||
className="font-medium hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contract.contractNumber}
|
||||
</Link>
|
||||
<Badge variant={urgencyBadgeVariants[contract.highestUrgency]}>
|
||||
{contract.issues.length} {contract.highestUrgency === 'ok'
|
||||
? (contract.issues.length === 1 ? 'Hinweis' : 'Hinweise')
|
||||
: (contract.issues.length === 1 ? 'Problem' : 'Probleme')}
|
||||
</Badge>
|
||||
<span className="text-sm">
|
||||
{typeLabels[contract.type]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm mt-1">
|
||||
<Link
|
||||
to={`/customers/${contract.customer.id}`}
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contract.customer.customerNumber} - {contract.customer.name}
|
||||
</Link>
|
||||
{(contract.provider?.name || contract.providerName) && (
|
||||
<span className="ml-2">
|
||||
| {contract.provider?.name || contract.providerName}
|
||||
{(contract.tariff?.name || contract.tariffName) &&
|
||||
` - ${contract.tariff?.name || contract.tariffName}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Link
|
||||
to={`/contracts/${contract.id}`}
|
||||
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
|
||||
className="ml-4 p-2 hover:bg-white hover:bg-opacity-50 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Zum Vertrag"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Expanded: Issues */}
|
||||
{isExpanded && (
|
||||
<div className="border-t px-4 py-3 bg-white bg-opacity-50">
|
||||
<div className="space-y-2">
|
||||
{contract.issues.map((issue, idx) => {
|
||||
const IssueIcon = issueTypeIcons[issue.type] || AlertCircle;
|
||||
const UrgencyIcon = issue.urgency === 'critical' ? AlertCircle :
|
||||
issue.urgency === 'warning' ? AlertTriangle :
|
||||
issue.urgency === 'ok' ? CheckCircle : Clock;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-3 text-sm"
|
||||
>
|
||||
<UrgencyIcon className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
|
||||
issue.urgency === 'critical' ? 'text-red-500' :
|
||||
issue.urgency === 'warning' ? 'text-yellow-500' :
|
||||
issue.urgency === 'ok' ? 'text-green-500' : 'text-gray-500'
|
||||
}`} />
|
||||
<IssueIcon className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-500" />
|
||||
<div>
|
||||
<span className="font-medium">{issue.label}</span>
|
||||
{issue.details && (
|
||||
<span className="text-gray-600 ml-2">{issue.details}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
<h1 className="text-2xl font-bold">Vertrags-Cockpit</h1>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/deadlines"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Fristenschwellen anpassen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="!p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.criticalCount}</p>
|
||||
<p className="text-sm text-gray-500">Kritisch (<{thresholds.criticalDays} Tage)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.warningCount}</p>
|
||||
<p className="text-sm text-gray-500">Warnung (<{thresholds.warningDays} Tage)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.okCount}</p>
|
||||
<p className="text-sm text-gray-500">OK (<{thresholds.okDays} Tage)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-600">{summary.totalContracts}</p>
|
||||
<p className="text-sm text-gray-500">Verträge mit Handlungsbedarf</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category Summary */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(summary.byCategory).map(([key, count]) => (
|
||||
count > 0 && (
|
||||
<div key={key} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{categoryLabels[key] || key}:</span>
|
||||
<Badge variant="default">{count}</Badge>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">Filter:</span>
|
||||
<Select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterType)}
|
||||
options={[
|
||||
{ value: 'all', label: `Alle (${cockpitData.data.contracts.length})` },
|
||||
{ value: 'critical', label: `Kritisch (${summary.criticalCount})` },
|
||||
{ value: 'warning', label: `Warnung (${summary.warningCount})` },
|
||||
{ value: 'ok', label: `OK (${summary.okCount})` },
|
||||
{ value: 'deadlines', label: `Fristen (${summary.byCategory.cancellationDeadlines + summary.byCategory.contractEnding})` },
|
||||
{ value: 'credentials', label: `Zugangsdaten (${summary.byCategory.missingCredentials})` },
|
||||
{ value: 'data', label: `Fehlende Daten (${summary.byCategory.missingData})` },
|
||||
{ value: 'tasks', label: `Aufgaben/Status (${summary.byCategory.openTasks + summary.byCategory.pendingContracts})` },
|
||||
]}
|
||||
className="w-64"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredContracts.length} Verträge angezeigt
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Contract List */}
|
||||
{filteredContracts.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{filter === 'all' ? (
|
||||
<>
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-500" />
|
||||
<p className="text-lg font-medium">Alles in Ordnung!</p>
|
||||
<p>Keine Verträge mit Handlungsbedarf gefunden.</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Keine Verträge für diesen Filter gefunden.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{filteredContracts.map(renderContract)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { contractApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Search, Eye, Edit, Trash2, User, Users } from 'lucide-react';
|
||||
import type { Contract, ContractType, ContractStatus } from '../../types';
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
ELECTRICITY: 'Strom',
|
||||
GAS: 'Gas',
|
||||
DSL: 'DSL',
|
||||
CABLE: 'Kabelinternet',
|
||||
FIBER: 'Glasfaser',
|
||||
MOBILE: 'Mobilfunk',
|
||||
TV: 'TV',
|
||||
CAR_INSURANCE: 'KFZ-Versicherung',
|
||||
};
|
||||
|
||||
const statusLabels: Record<ContractStatus, string> = {
|
||||
DRAFT: 'Entwurf',
|
||||
PENDING: 'Ausstehend',
|
||||
ACTIVE: 'Aktiv',
|
||||
CANCELLED: 'Gekündigt',
|
||||
EXPIRED: 'Abgelaufen',
|
||||
DEACTIVATED: 'Deaktiviert',
|
||||
};
|
||||
|
||||
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
||||
ACTIVE: 'success',
|
||||
PENDING: 'warning',
|
||||
CANCELLED: 'danger',
|
||||
EXPIRED: 'danger',
|
||||
DRAFT: 'default',
|
||||
DEACTIVATED: 'default',
|
||||
};
|
||||
|
||||
export default function ContractList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filter-Werte aus URL-Parametern lesen
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [type, setType] = useState(searchParams.get('type') || '');
|
||||
const [status, setStatus] = useState(searchParams.get('status') || '');
|
||||
const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10));
|
||||
|
||||
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// URL-Parameter aktualisieren wenn sich Filter ändern
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (type) params.set('type', type);
|
||||
if (status) params.set('status', status);
|
||||
if (page > 1) params.set('page', page.toString());
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [search, type, status, page, setSearchParams]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: contractApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contracts', search, type, status, page, isCustomer ? user?.customerId : null],
|
||||
queryFn: () =>
|
||||
contractApi.getAll({
|
||||
search: search || undefined,
|
||||
type: type || undefined,
|
||||
status: status || undefined,
|
||||
page,
|
||||
limit: 20,
|
||||
customerId: isCustomer ? user?.customerId : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// Für Kundenportal: Verträge nach Kunde gruppieren
|
||||
const groupedContracts = useMemo(() => {
|
||||
if (!isCustomerPortal || !data?.data) return null;
|
||||
|
||||
const groups: Record<number, { customerName: string; isOwn: boolean; contracts: Contract[] }> = {};
|
||||
|
||||
for (const contract of data.data) {
|
||||
const customerId = contract.customerId;
|
||||
if (!groups[customerId]) {
|
||||
const customerName = contract.customer
|
||||
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
|
||||
: `Kunde ${customerId}`;
|
||||
groups[customerId] = {
|
||||
customerName,
|
||||
isOwn: customerId === user?.customerId,
|
||||
contracts: [],
|
||||
};
|
||||
}
|
||||
groups[customerId].contracts.push(contract);
|
||||
}
|
||||
|
||||
// Eigene Verträge zuerst, dann alphabetisch nach Name
|
||||
return Object.values(groups).sort((a, b) => {
|
||||
if (a.isOwn && !b.isOwn) return -1;
|
||||
if (!a.isOwn && b.isOwn) return 1;
|
||||
return a.customerName.localeCompare(b.customerName);
|
||||
});
|
||||
}, [data?.data, isCustomerPortal, user?.customerId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Verträge</h1>
|
||||
{hasPermission('contracts:create') && !isCustomer && (
|
||||
<Link to="/contracts/new">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Vertrag
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
options={Object.entries(typeLabels).map(([value, label]) => ({ value, label }))}
|
||||
className="w-48"
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button variant="secondary">
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
</Card>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<>
|
||||
{/* Kundenportal: Gruppierte Ansicht */}
|
||||
{isCustomerPortal && groupedContracts ? (
|
||||
<div className="space-y-6">
|
||||
{groupedContracts.map((group) => (
|
||||
<Card key={group.isOwn ? 'own' : group.customerName}>
|
||||
{/* Gruppen-Header */}
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
|
||||
{group.isOwn ? (
|
||||
<>
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Meine Verträge</h2>
|
||||
<Badge variant="default">{group.contracts.length}</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Verträge von {group.customerName}
|
||||
</h2>
|
||||
<Badge variant="default">{group.contracts.length}</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertrags-Tabelle */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Vertragsnr.</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.contracts.map((contract) => (
|
||||
<tr key={contract.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge>{typeLabels[contract.type]}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{contract.providerName || '-'}
|
||||
{contract.tariffName && (
|
||||
<span className="text-gray-500"> / {contract.tariffName}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={statusVariants[contract.status]}>
|
||||
{statusLabels[contract.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{contract.startDate
|
||||
? new Date(contract.startDate).toLocaleDateString('de-DE')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/contracts/${contract.id}`, {
|
||||
state: { from: 'contracts' }
|
||||
})}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Standard-Ansicht für Mitarbeiter */
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Vertragsnr.</th>
|
||||
{!isCustomer && (
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Kunde</th>
|
||||
)}
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((contract) => (
|
||||
<tr key={contract.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
|
||||
{!isCustomer && (
|
||||
<td className="py-3 px-4">
|
||||
{contract.customer && (
|
||||
<Link
|
||||
to={`/customers/${contract.customer.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{contract.customer.companyName ||
|
||||
`${contract.customer.firstName} ${contract.customer.lastName}`}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 px-4">
|
||||
<Badge>{typeLabels[contract.type]}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{contract.providerName || '-'}
|
||||
{contract.tariffName && (
|
||||
<span className="text-gray-500"> / {contract.tariffName}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={statusVariants[contract.status]}>
|
||||
{statusLabels[contract.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{contract.startDate
|
||||
? new Date(contract.startDate).toLocaleDateString('de-DE')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/contracts/${contract.id}`, {
|
||||
state: { from: 'contracts' }
|
||||
})}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{hasPermission('contracts:update') && !isCustomer && (
|
||||
<Link to={`/contracts/${contract.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('contracts:delete') && !isCustomer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Vertrag wirklich löschen?')) {
|
||||
deleteMutation.mutate(contract.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.pagination && data.pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Seite {data.pagination.page} von {data.pagination.totalPages} (
|
||||
{data.pagination.total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= data.pagination.totalPages}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { customerApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import type { Customer } from '../../types';
|
||||
|
||||
type CustomerFormData = Omit<Customer, 'id' | 'customerNumber' | 'createdAt' | 'updatedAt' | 'addresses' | 'bankCards' | 'identityDocuments' | 'meters' | 'contracts'>;
|
||||
|
||||
export default function CustomerForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
|
||||
const customerType = watch('type');
|
||||
|
||||
const { data: customer } = useQuery({
|
||||
queryKey: ['customer', id],
|
||||
queryFn: () => customerApi.getById(parseInt(id!)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (customer?.data) {
|
||||
const data = { ...customer.data };
|
||||
// Convert date strings to YYYY-MM-DD format for date inputs
|
||||
if (data.birthDate) {
|
||||
data.birthDate = data.birthDate.split('T')[0] as any;
|
||||
}
|
||||
if (data.foundingDate) {
|
||||
data.foundingDate = data.foundingDate.split('T')[0] as any;
|
||||
}
|
||||
reset(data);
|
||||
}
|
||||
}, [customer, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: customerApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
navigate('/customers');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<Customer>) => customerApi.update(parseInt(id!), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', id] });
|
||||
navigate(`/customers/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CustomerFormData) => {
|
||||
// Only include the fields that can be updated - exclude relations and read-only fields
|
||||
const submitData: any = {
|
||||
type: data.type,
|
||||
salutation: data.salutation || undefined,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
companyName: data.companyName || undefined,
|
||||
email: data.email || undefined,
|
||||
phone: data.phone || undefined,
|
||||
mobile: data.mobile || undefined,
|
||||
taxNumber: data.taxNumber || undefined,
|
||||
commercialRegisterNumber: data.commercialRegisterNumber || undefined,
|
||||
notes: data.notes || undefined,
|
||||
birthPlace: data.birthPlace || undefined,
|
||||
};
|
||||
|
||||
// Handle birthDate - convert non-empty string to ISO string, or null to clear
|
||||
if (data.birthDate && typeof data.birthDate === 'string' && data.birthDate.trim() !== '') {
|
||||
submitData.birthDate = new Date(data.birthDate).toISOString();
|
||||
} else {
|
||||
submitData.birthDate = null;
|
||||
}
|
||||
|
||||
// Handle foundingDate for business customers - or null to clear
|
||||
if (data.foundingDate && typeof data.foundingDate === 'string' && data.foundingDate.trim() !== '') {
|
||||
submitData.foundingDate = new Date(data.foundingDate).toISOString();
|
||||
} else {
|
||||
submitData.foundingDate = null;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
const error = createMutation.error || updateMutation.error;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
{isEdit ? 'Kunde bearbeiten' : 'Neuer Kunde'}
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card className="mb-6" title="Stammdaten">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Kundentyp"
|
||||
{...register('type')}
|
||||
options={[
|
||||
{ value: 'PRIVATE', label: 'Privatkunde' },
|
||||
{ value: 'BUSINESS', label: 'Geschäftskunde' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Anrede"
|
||||
{...register('salutation')}
|
||||
options={[
|
||||
{ value: 'Herr', label: 'Herr' },
|
||||
{ value: 'Frau', label: 'Frau' },
|
||||
{ value: 'Divers', label: 'Divers' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Vorname"
|
||||
{...register('firstName', { required: 'Vorname erforderlich' })}
|
||||
error={errors.firstName?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nachname"
|
||||
{...register('lastName', { required: 'Nachname erforderlich' })}
|
||||
error={errors.lastName?.message}
|
||||
/>
|
||||
|
||||
{customerType === 'BUSINESS' && (
|
||||
<>
|
||||
<Input
|
||||
label="Firmenname"
|
||||
{...register('companyName')}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
<Input
|
||||
label="Gründungsdatum"
|
||||
type="date"
|
||||
{...register('foundingDate')}
|
||||
value={watch('foundingDate') || ''}
|
||||
onClear={() => setValue('foundingDate', '' as any)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{customerType !== 'BUSINESS' && (
|
||||
<>
|
||||
<Input
|
||||
label="Geburtsdatum"
|
||||
type="date"
|
||||
{...register('birthDate')}
|
||||
value={watch('birthDate') || ''}
|
||||
onClear={() => setValue('birthDate', '' as any)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Geburtsort"
|
||||
{...register('birthPlace')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6" title="Kontaktdaten">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Telefon"
|
||||
{...register('phone')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Mobil"
|
||||
{...register('mobile')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{customerType === 'BUSINESS' && (
|
||||
<Card className="mb-6" title="Geschäftsdaten">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Steuernummer"
|
||||
{...register('taxNumber')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Handelsregisternummer"
|
||||
{...register('commercialRegisterNumber')}
|
||||
placeholder="z.B. HRB 12345"
|
||||
/>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Dokumente (Gewerbeanmeldung, Handelsregisterauszug) können nach dem Speichern in der Kundendetailansicht hochgeladen werden.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mb-6" title="Notizen">
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={4}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Interne Notizen..."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { customerApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Search, Eye, Edit } from 'lucide-react';
|
||||
|
||||
export default function CustomerList() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['customers', search, type, page],
|
||||
queryFn: () => customerApi.getAll({ search, type: type || undefined, page, limit: 20 }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Kunden</h1>
|
||||
{hasPermission('customers:create') && (
|
||||
<Link to="/customers/new">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Kunde
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg w-28 flex-shrink-0"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="PRIVATE">Privat</option>
|
||||
<option value="BUSINESS">Firma</option>
|
||||
</select>
|
||||
<Button variant="secondary" className="flex-shrink-0">
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Kundennr.</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">E-Mail</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Verträge</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((customer) => (
|
||||
<tr key={customer.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">{customer.customerNumber}</td>
|
||||
<td className="py-3 px-4">
|
||||
{customer.type === 'BUSINESS' && customer.companyName
|
||||
? customer.companyName
|
||||
: `${customer.firstName} ${customer.lastName}`}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={customer.type === 'BUSINESS' ? 'info' : 'default'}>
|
||||
{customer.type === 'BUSINESS' ? 'Firma' : 'Privat'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">{customer.email || '-'}</td>
|
||||
<td className="py-3 px-4">
|
||||
{(customer as any)._count?.contracts || 0}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link to={`/customers/${customer.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{hasPermission('customers:update') && (
|
||||
<Link to={`/customers/${customer.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.pagination && data.pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Seite {data.pagination.page} von {data.pagination.totalPages} ({data.pagination.total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= data.pagination.totalPages}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Keine Kunden gefunden.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { developerApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { Database, Table, ArrowRight, Edit, Trash2, Save, X, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react';
|
||||
import ERDiagram from './ERDiagram';
|
||||
|
||||
interface TableMeta {
|
||||
name: string;
|
||||
model: string;
|
||||
primaryKey: string;
|
||||
readonlyFields: string[];
|
||||
requiredFields: string[];
|
||||
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
|
||||
foreignKeys: { field: string; targetTable: string }[];
|
||||
}
|
||||
|
||||
export default function DatabaseStructure() {
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [editingRow, setEditingRow] = useState<{ id: string; data: Record<string, any> } | null>(null);
|
||||
const [showERDiagram, setShowERDiagram] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: schemaData, isLoading: schemaLoading, error: schemaError } = useQuery({
|
||||
queryKey: ['developer-schema'],
|
||||
queryFn: developerApi.getSchema,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('Schema data:', schemaData);
|
||||
console.log('Schema error:', schemaError);
|
||||
|
||||
const { data: tableData, isLoading: tableLoading } = useQuery({
|
||||
queryKey: ['developer-table', selectedTable, page],
|
||||
queryFn: () => developerApi.getTableData(selectedTable!, page),
|
||||
enabled: !!selectedTable,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ tableName, id, data }: { tableName: string; id: string; data: Record<string, any> }) =>
|
||||
developerApi.updateRow(tableName, id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
|
||||
setEditingRow(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.response?.data?.error || 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ tableName, id }: { tableName: string; id: string }) =>
|
||||
developerApi.deleteRow(tableName, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error.response?.data?.error || 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const tables: TableMeta[] = schemaData?.data || [];
|
||||
const currentTableMeta = tables.find((t) => t.name === selectedTable);
|
||||
|
||||
const getRowId = (row: any, meta: TableMeta) => {
|
||||
if (meta.primaryKey.includes(',')) {
|
||||
return meta.primaryKey.split(',').map((k) => row[k]).join('-');
|
||||
}
|
||||
return String(row[meta.primaryKey]);
|
||||
};
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
|
||||
if (typeof value === 'object') {
|
||||
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
|
||||
return new Date(value).toLocaleString('de-DE');
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingRow || !selectedTable) return;
|
||||
updateMutation.mutate({
|
||||
tableName: selectedTable,
|
||||
id: editingRow.id,
|
||||
data: editingRow.data,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (!selectedTable) return;
|
||||
if (!confirm('Datensatz wirklich löschen?')) return;
|
||||
deleteMutation.mutate({ tableName: selectedTable, id });
|
||||
};
|
||||
|
||||
if (schemaLoading) {
|
||||
return <div className="text-center py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
const handleDiagramSelectTable = (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
setPage(1);
|
||||
setShowERDiagram(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Datenbankstruktur</h1>
|
||||
</div>
|
||||
<Button onClick={() => setShowERDiagram(true)}>
|
||||
<GitBranch className="w-4 h-4 mr-2" />
|
||||
ER-Diagramm
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Tabellen-Liste */}
|
||||
<Card title="Tabellen" className="lg:col-span-1">
|
||||
<div className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||
{tables.map((table) => (
|
||||
<button
|
||||
key={table.name}
|
||||
onClick={() => {
|
||||
setSelectedTable(table.name);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg flex items-center gap-2 transition-colors ${
|
||||
selectedTable === table.name
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Table className="w-4 h-4" />
|
||||
<span className="text-sm font-mono">{table.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabellen-Details und Daten */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{selectedTable && currentTableMeta ? (
|
||||
<>
|
||||
{/* Beziehungen */}
|
||||
<Card title={`${selectedTable} - Beziehungen`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Fremdschlüssel (referenziert)</h4>
|
||||
{currentTableMeta.foreignKeys.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{currentTableMeta.foreignKeys.map((fk) => (
|
||||
<div key={fk.field} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-mono text-gray-600">{fk.field}</span>
|
||||
<ArrowRight className="w-4 h-4 text-gray-400" />
|
||||
<Badge
|
||||
variant="info"
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedTable(fk.targetTable);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{fk.targetTable}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Keine</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">Relationen (wird referenziert von)</h4>
|
||||
{currentTableMeta.relations.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{currentTableMeta.relations.map((rel) => (
|
||||
<div key={rel.field} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-mono text-gray-600">{rel.field}</span>
|
||||
<Badge variant={rel.type === 'many' ? 'warning' : 'default'}>
|
||||
{rel.type === 'many' ? '1:n' : '1:1'}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="info"
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedTable(rel.targetTable);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{rel.targetTable}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Keine</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Primary Key:</span>{' '}
|
||||
<span className="font-mono">{currentTableMeta.primaryKey}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Readonly:</span>{' '}
|
||||
<span className="font-mono text-red-600">{currentTableMeta.readonlyFields.join(', ') || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Required:</span>{' '}
|
||||
<span className="font-mono text-green-600">{currentTableMeta.requiredFields.join(', ') || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Daten-Tabelle */}
|
||||
<Card title={`${selectedTable} - Daten`}>
|
||||
{tableLoading ? (
|
||||
<div className="text-center py-4">Laden...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-50">
|
||||
{tableData?.data && tableData.data.length > 0 &&
|
||||
Object.keys(tableData.data[0]).map((key) => (
|
||||
<th key={key} className="text-left py-2 px-3 font-medium text-gray-600 whitespace-nowrap">
|
||||
{key}
|
||||
{currentTableMeta.readonlyFields.includes(key) && (
|
||||
<span className="ml-1 text-red-400 text-xs">*</span>
|
||||
)}
|
||||
{currentTableMeta.requiredFields.includes(key) && (
|
||||
<span className="ml-1 text-green-400 text-xs">!</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-2 px-3 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData?.data?.map((row: any) => {
|
||||
const rowId = getRowId(row, currentTableMeta);
|
||||
return (
|
||||
<tr key={rowId} className="border-b hover:bg-gray-50">
|
||||
{Object.entries(row).map(([key, value]) => (
|
||||
<td key={key} className="py-2 px-3 font-mono text-xs max-w-[200px] truncate">
|
||||
{formatValue(value)}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 px-3 text-right whitespace-nowrap">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingRow({ id: rowId, data: { ...row } })}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rowId)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(!tableData?.data || tableData.data.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={100} className="py-4 text-center text-gray-500">
|
||||
Keine Daten vorhanden
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(tableData as any)?.pagination && (tableData as any).pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Seite {(tableData as any).pagination.page} von {(tableData as any).pagination.totalPages} ({(tableData as any).pagination.total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= (tableData as any).pagination.totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Wähle eine Tabelle aus der Liste aus
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
isOpen={!!editingRow}
|
||||
onClose={() => setEditingRow(null)}
|
||||
title={`${selectedTable} bearbeiten`}
|
||||
>
|
||||
{editingRow && currentTableMeta && (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{Object.entries(editingRow.data).map(([key, value]) => {
|
||||
const isReadonly = currentTableMeta.readonlyFields.includes(key);
|
||||
const isRequired = currentTableMeta.requiredFields.includes(key);
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{isReadonly && <span className="ml-1 text-red-400">(readonly)</span>}
|
||||
{isRequired && <span className="ml-1 text-green-600">*</span>}
|
||||
</label>
|
||||
{isReadonly ? (
|
||||
<div className="px-3 py-2 bg-gray-100 rounded-lg font-mono text-sm">
|
||||
{formatValue(value)}
|
||||
</div>
|
||||
) : typeof value === 'boolean' ? (
|
||||
<select
|
||||
value={String(editingRow.data[key])}
|
||||
onChange={(e) =>
|
||||
setEditingRow({
|
||||
...editingRow,
|
||||
data: { ...editingRow.data, [key]: e.target.value === 'true' },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="true">Ja</option>
|
||||
<option value="false">Nein</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
value={editingRow.data[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setEditingRow({
|
||||
...editingRow,
|
||||
data: {
|
||||
...editingRow.data,
|
||||
[key]: typeof value === 'number' ? (e.target.value ? Number(e.target.value) : null) : e.target.value || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="secondary" onClick={() => setEditingRow(null)}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{updateMutation.isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ER Diagram Modal */}
|
||||
{showERDiagram && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setShowERDiagram(false)} />
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-[90vw] h-[85vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold">ER-Diagramm - Datenbankbeziehungen</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowERDiagram(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ERDiagram onSelectTable={handleDiagramSelectTable} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { developerApi } from '../../services/api';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Move } from 'lucide-react';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
interface TableMeta {
|
||||
name: string;
|
||||
model: string;
|
||||
primaryKey: string;
|
||||
readonlyFields: string[];
|
||||
requiredFields: string[];
|
||||
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
|
||||
foreignKeys: { field: string; targetTable: string }[];
|
||||
}
|
||||
|
||||
interface TablePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ERDiagramProps {
|
||||
onSelectTable?: (tableName: string) => void;
|
||||
}
|
||||
|
||||
export default function ERDiagram({ onSelectTable }: ERDiagramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [tablePositions, setTablePositions] = useState<Record<string, TablePosition>>({});
|
||||
const [draggingTable, setDraggingTable] = useState<string | null>(null);
|
||||
|
||||
const { data: schemaData, isLoading } = useQuery({
|
||||
queryKey: ['developer-schema'],
|
||||
queryFn: developerApi.getSchema,
|
||||
});
|
||||
|
||||
const tables: TableMeta[] = schemaData?.data || [];
|
||||
|
||||
// Calculate initial positions in a grid layout
|
||||
useEffect(() => {
|
||||
if (tables.length > 0 && Object.keys(tablePositions).length === 0) {
|
||||
const cols = Math.ceil(Math.sqrt(tables.length));
|
||||
const spacing = { x: 280, y: 200 };
|
||||
const newPositions: Record<string, TablePosition> = {};
|
||||
|
||||
tables.forEach((table, index) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
newPositions[table.name] = {
|
||||
x: 50 + col * spacing.x,
|
||||
y: 50 + row * spacing.y,
|
||||
};
|
||||
});
|
||||
|
||||
setTablePositions(newPositions);
|
||||
}
|
||||
}, [tables, tablePositions]);
|
||||
|
||||
// Pan handling
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'svg') {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}
|
||||
}, [pan]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isDragging && !draggingTable) {
|
||||
setPan({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
} else if (draggingTable) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setTablePositions(prev => ({
|
||||
...prev,
|
||||
[draggingTable]: {
|
||||
x: (e.clientX - rect.left - pan.x) / zoom - 100,
|
||||
y: (e.clientY - rect.top - pan.y) / zoom - 20,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [isDragging, draggingTable, dragStart, pan, zoom]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setDraggingTable(null);
|
||||
}, []);
|
||||
|
||||
const handleZoom = (delta: number) => {
|
||||
setZoom(prev => Math.min(2, Math.max(0.3, prev + delta)));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
// Calculate connection points between tables
|
||||
const getConnections = useCallback(() => {
|
||||
const connections: Array<{
|
||||
from: { table: string; x: number; y: number };
|
||||
to: { table: string; x: number; y: number };
|
||||
type: 'one' | 'many';
|
||||
label: string;
|
||||
}> = [];
|
||||
|
||||
tables.forEach(table => {
|
||||
const fromPos = tablePositions[table.name];
|
||||
if (!fromPos) return;
|
||||
|
||||
table.foreignKeys.forEach(fk => {
|
||||
const toPos = tablePositions[fk.targetTable];
|
||||
if (!toPos) return;
|
||||
|
||||
// Find the relation type from the target table
|
||||
const targetTable = tables.find(t => t.name === fk.targetTable);
|
||||
const relation = targetTable?.relations.find(r => r.targetTable === table.name);
|
||||
|
||||
connections.push({
|
||||
from: { table: table.name, x: fromPos.x + 100, y: fromPos.y + 60 },
|
||||
to: { table: fk.targetTable, x: toPos.x + 100, y: toPos.y + 60 },
|
||||
type: relation?.type || 'one',
|
||||
label: fk.field,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return connections;
|
||||
}, [tables, tablePositions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-full">Laden...</div>;
|
||||
}
|
||||
|
||||
const connections = getConnections();
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-gray-50 overflow-hidden" ref={containerRef}>
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-4 right-4 z-10 flex gap-2 bg-white rounded-lg shadow-md p-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleZoom(0.1)} title="Vergrößern">
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleZoom(-0.1)} title="Verkleinern">
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title="Zurücksetzen">
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="text-xs text-gray-500 flex items-center px-2">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="absolute top-4 left-4 z-10 bg-white rounded-lg shadow-md p-2 text-xs text-gray-500">
|
||||
<Move className="w-3 h-3 inline mr-1" />
|
||||
Tabellen ziehen zum Verschieben
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas */}
|
||||
<svg
|
||||
className="w-full h-full cursor-grab"
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* Connection lines */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
|
||||
</marker>
|
||||
<marker
|
||||
id="many-marker"
|
||||
markerWidth="12"
|
||||
markerHeight="12"
|
||||
refX="6"
|
||||
refY="6"
|
||||
orient="auto"
|
||||
>
|
||||
<circle cx="6" cy="6" r="3" fill="#6b7280" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{connections.map((conn, idx) => {
|
||||
const dx = conn.to.x - conn.from.x;
|
||||
const dy = conn.to.y - conn.from.y;
|
||||
const midX = conn.from.x + dx / 2;
|
||||
const midY = conn.from.y + dy / 2;
|
||||
|
||||
// Calculate control points for curved line
|
||||
const ctrl1X = conn.from.x + dx * 0.25;
|
||||
const ctrl1Y = conn.from.y;
|
||||
const ctrl2X = conn.from.x + dx * 0.75;
|
||||
const ctrl2Y = conn.to.y;
|
||||
|
||||
return (
|
||||
<g key={idx}>
|
||||
<path
|
||||
d={`M ${conn.from.x} ${conn.from.y} C ${ctrl1X} ${ctrl1Y}, ${ctrl2X} ${ctrl2Y}, ${conn.to.x} ${conn.to.y}`}
|
||||
fill="none"
|
||||
stroke="#9ca3af"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
{/* Relation type indicator */}
|
||||
<text
|
||||
x={midX}
|
||||
y={midY - 8}
|
||||
fontSize="10"
|
||||
fill="#6b7280"
|
||||
textAnchor="middle"
|
||||
className="select-none"
|
||||
>
|
||||
{conn.type === 'many' ? '1:n' : '1:1'}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Table boxes */}
|
||||
{tables.map(table => {
|
||||
const pos = tablePositions[table.name];
|
||||
if (!pos) return null;
|
||||
|
||||
const boxWidth = 200;
|
||||
const headerHeight = 32;
|
||||
const fieldHeight = 20;
|
||||
const fields = [...new Set([table.primaryKey, ...table.foreignKeys.map(fk => fk.field)])];
|
||||
const boxHeight = headerHeight + Math.min(fields.length, 5) * fieldHeight + 8;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={table.name}
|
||||
transform={`translate(${pos.x}, ${pos.y})`}
|
||||
style={{ cursor: 'move' }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraggingTable(table.name);
|
||||
}}
|
||||
>
|
||||
{/* Shadow */}
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width={boxWidth}
|
||||
height={boxHeight}
|
||||
rx="6"
|
||||
fill="rgba(0,0,0,0.1)"
|
||||
/>
|
||||
{/* Box background */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={boxWidth}
|
||||
height={boxHeight}
|
||||
rx="6"
|
||||
fill="white"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Header */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={boxWidth}
|
||||
height={headerHeight}
|
||||
rx="6"
|
||||
fill="#3b82f6"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectTable?.(table.name)}
|
||||
/>
|
||||
<rect
|
||||
x="0"
|
||||
y={headerHeight - 6}
|
||||
width={boxWidth}
|
||||
height="6"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<text
|
||||
x={boxWidth / 2}
|
||||
y="21"
|
||||
fontSize="13"
|
||||
fontWeight="bold"
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
className="select-none pointer-events-none"
|
||||
>
|
||||
{table.name}
|
||||
</text>
|
||||
|
||||
{/* Fields */}
|
||||
{fields.slice(0, 5).map((field, fieldIdx) => {
|
||||
const isPK = field === table.primaryKey || table.primaryKey.includes(field);
|
||||
const isFK = table.foreignKeys.some(fk => fk.field === field);
|
||||
|
||||
return (
|
||||
<g key={field} transform={`translate(8, ${headerHeight + 4 + fieldIdx * fieldHeight})`}>
|
||||
<text
|
||||
x="0"
|
||||
y="14"
|
||||
fontSize="11"
|
||||
fill={isPK ? '#dc2626' : isFK ? '#2563eb' : '#374151'}
|
||||
fontFamily="monospace"
|
||||
className="select-none"
|
||||
>
|
||||
{isPK && '🔑 '}
|
||||
{isFK && !isPK && '🔗 '}
|
||||
{field}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{fields.length > 5 && (
|
||||
<text
|
||||
x={boxWidth / 2}
|
||||
y={boxHeight - 4}
|
||||
fontSize="10"
|
||||
fill="#9ca3af"
|
||||
textAnchor="middle"
|
||||
className="select-none"
|
||||
>
|
||||
+{fields.length - 5} mehr...
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-md p-3 text-xs">
|
||||
<div className="font-medium mb-2">Legende</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-600">🔑</span>
|
||||
<span>Primary Key</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">🔗</span>
|
||||
<span>Foreign Key</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-0.5 bg-gray-400"></div>
|
||||
<span>Beziehung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { platformApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import type { SalesPlatform } from '../../types';
|
||||
|
||||
export default function PlatformList() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPlatform, setEditingPlatform] = useState<SalesPlatform | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['platforms', showInactive],
|
||||
queryFn: () => platformApi.getAll(showInactive),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: platformApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['platforms'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (platform: SalesPlatform) => {
|
||||
setEditingPlatform(platform);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingPlatform(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Vertriebsplattformen</h1>
|
||||
{hasPermission('platforms:create') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neue Plattform
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Kontakt</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((platform) => (
|
||||
<tr key={platform.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-medium">{platform.name}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{platform.contactInfo || '-'}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={platform.isActive ? 'success' : 'danger'}>
|
||||
{platform.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasPermission('platforms:update') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(platform)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('platforms:delete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Plattform wirklich löschen?')) {
|
||||
deleteMutation.mutate(platform.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Plattformen vorhanden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<PlatformModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
platform={editingPlatform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
platform,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
platform: SalesPlatform | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
contactInfo: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useState(() => {
|
||||
if (platform) {
|
||||
setFormData({
|
||||
name: platform.name,
|
||||
contactInfo: platform.contactInfo || '',
|
||||
isActive: platform.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({ name: '', contactInfo: '', isActive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form when platform changes
|
||||
if (platform && formData.name !== platform.name) {
|
||||
setFormData({
|
||||
name: platform.name,
|
||||
contactInfo: platform.contactInfo || '',
|
||||
isActive: platform.isActive,
|
||||
});
|
||||
} else if (!platform && formData.name !== '') {
|
||||
// Only reset if we're opening for new (not editing)
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: platformApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['platforms'] });
|
||||
onClose();
|
||||
setFormData({ name: '', contactInfo: '', isActive: true });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<SalesPlatform>) =>
|
||||
platformApi.update(platform!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['platforms'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (platform) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={platform ? 'Plattform bearbeiten' : 'Neue Plattform'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Name *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kontaktinformationen
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.contactInfo}
|
||||
onChange={(e) => setFormData({ ...formData, contactInfo: e.target.value })}
|
||||
rows={3}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="E-Mail, Telefon, Ansprechpartner..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{platform && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { cancellationPeriodApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { CancellationPeriod } from '../../types';
|
||||
|
||||
export default function CancellationPeriodList() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPeriod, setEditingPeriod] = useState<CancellationPeriod | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['cancellation-periods', showInactive],
|
||||
queryFn: () => cancellationPeriodApi.getAll(showInactive),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: cancellationPeriodApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (period: CancellationPeriod) => {
|
||||
setEditingPeriod(period);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingPeriod(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
|
||||
{hasPermission('platforms:create') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neue Frist
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
|
||||
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
|
||||
<br />
|
||||
<strong>Beispiele:</strong> 14T = 14 Tage, 3M = 3 Monate, 1J = 1 Jahr
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((period) => (
|
||||
<tr key={period.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono font-medium">{period.code}</td>
|
||||
<td className="py-3 px-4">{period.description}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={period.isActive ? 'success' : 'danger'}>
|
||||
{period.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasPermission('platforms:update') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('platforms:delete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Kündigungsfrist wirklich löschen?')) {
|
||||
deleteMutation.mutate(period.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Kündigungsfristen vorhanden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CancellationPeriodModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
period={editingPeriod}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CancellationPeriodModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
period,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
period: CancellationPeriod | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Reset form when modal opens or period changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (period) {
|
||||
setFormData({
|
||||
code: period.code,
|
||||
description: period.description,
|
||||
isActive: period.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({ code: '', description: '', isActive: true });
|
||||
}
|
||||
}
|
||||
}, [isOpen, period]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: cancellationPeriodApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
|
||||
onClose();
|
||||
setFormData({ code: '', description: '', isActive: true });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<CancellationPeriod>) =>
|
||||
cancellationPeriodApi.update(period!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (period) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={period ? 'Kündigungsfrist bearbeiten' : 'Neue Kündigungsfrist'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Code *"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
required
|
||||
placeholder="z.B. 14T, 3M, 1J"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Beschreibung *"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
required
|
||||
placeholder="z.B. 14 Tage, 3 Monate, 1 Jahr"
|
||||
/>
|
||||
|
||||
{period && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractCategoryApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ContractCategory } from '../../types';
|
||||
|
||||
// Icon-Mapping für die Anzeige
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
Zap: <Zap className="w-5 h-5" />,
|
||||
Flame: <Flame className="w-5 h-5" />,
|
||||
Wifi: <Wifi className="w-5 h-5" />,
|
||||
Cable: <Cable className="w-5 h-5" />,
|
||||
Smartphone: <Smartphone className="w-5 h-5" />,
|
||||
Tv: <Tv className="w-5 h-5" />,
|
||||
Car: <Car className="w-5 h-5" />,
|
||||
FileText: <FileText className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const availableIcons = [
|
||||
{ value: 'Zap', label: 'Blitz (Strom)' },
|
||||
{ value: 'Flame', label: 'Flamme (Gas)' },
|
||||
{ value: 'Wifi', label: 'WLAN (DSL)' },
|
||||
{ value: 'Cable', label: 'Kabel (Glasfaser)' },
|
||||
{ value: 'Smartphone', label: 'Smartphone (Mobilfunk)' },
|
||||
{ value: 'Tv', label: 'TV' },
|
||||
{ value: 'Car', label: 'Auto (KFZ)' },
|
||||
{ value: 'FileText', label: 'Dokument (Sonstige)' },
|
||||
];
|
||||
|
||||
const availableColors = [
|
||||
{ value: '#FFC107', label: 'Gelb' },
|
||||
{ value: '#FF5722', label: 'Orange' },
|
||||
{ value: '#2196F3', label: 'Blau' },
|
||||
{ value: '#9C27B0', label: 'Lila' },
|
||||
{ value: '#4CAF50', label: 'Grün' },
|
||||
{ value: '#E91E63', label: 'Pink' },
|
||||
{ value: '#607D8B', label: 'Grau' },
|
||||
{ value: '#795548', label: 'Braun' },
|
||||
{ value: '#00BCD4', label: 'Cyan' },
|
||||
{ value: '#F44336', label: 'Rot' },
|
||||
];
|
||||
|
||||
export default function ContractCategoryList() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<ContractCategory | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract-categories', showInactive],
|
||||
queryFn: () => contractCategoryApi.getAll(showInactive),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: contractCategoryApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (category: ContractCategory) => {
|
||||
setEditingCategory(category);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingCategory(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
||||
{hasPermission('platforms:create') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Vertragstyp
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.data.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center p-4 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="mr-3 text-gray-400">
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</div>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center mr-4"
|
||||
style={{ backgroundColor: category.color || '#E5E7EB', color: '#fff' }}
|
||||
>
|
||||
{category.icon && iconMap[category.icon] ? iconMap[category.icon] : <FileText className="w-5 h-5" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Badge variant={category.isActive ? 'success' : 'danger'}>
|
||||
{category.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
({category._count?.contracts || 0} Verträge)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Code: <span className="font-mono">{category.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{hasPermission('platforms:update') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('platforms:delete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Vertragstyp wirklich löschen?')) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Vertragstypen vorhanden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ContractCategoryModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
category={editingCategory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractCategoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
category,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
category: ContractCategory | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
icon: 'FileText',
|
||||
color: '#607D8B',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (category) {
|
||||
setFormData({
|
||||
code: category.code,
|
||||
name: category.name,
|
||||
icon: category.icon || 'FileText',
|
||||
color: category.color || '#607D8B',
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
code: '',
|
||||
name: '',
|
||||
icon: 'FileText',
|
||||
color: '#607D8B',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, category]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: contractCategoryApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ContractCategory>) =>
|
||||
contractCategoryApi.update(category!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (category) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={category ? 'Vertragstyp bearbeiten' : 'Neuer Vertragstyp'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Code (technisch) *"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })}
|
||||
required
|
||||
placeholder="z.B. ELECTRICITY, MOBILE_BUSINESS"
|
||||
disabled={!!category} // Code nicht änderbar bei Bearbeitung
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Anzeigename *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="z.B. Strom, Mobilfunk Business"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Icon</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{availableIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: icon.value })}
|
||||
className={`p-3 border rounded-lg flex flex-col items-center gap-1 text-xs ${
|
||||
formData.icon === icon.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{iconMap[icon.value]}
|
||||
<span className="truncate w-full text-center">{icon.label.split(' ')[0]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Farbe</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableColors.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.value })}
|
||||
className={`w-8 h-8 rounded-full border-2 ${
|
||||
formData.color === color.value ? 'border-gray-800 ring-2 ring-offset-2 ring-gray-400' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Sortierung"
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
{category && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractDurationApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ContractDuration } from '../../types';
|
||||
|
||||
export default function ContractDurationList() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDuration, setEditingDuration] = useState<ContractDuration | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract-durations', showInactive],
|
||||
queryFn: () => contractDurationApi.getAll(showInactive),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: contractDurationApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (duration: ContractDuration) => {
|
||||
setEditingDuration(duration);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingDuration(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
|
||||
{hasPermission('platforms:create') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neue Laufzeit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
|
||||
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
|
||||
<br />
|
||||
<strong>Beispiele:</strong> 12M = 12 Monate, 24M = 24 Monate, 2J = 2 Jahre
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((duration) => (
|
||||
<tr key={duration.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-mono font-medium">{duration.code}</td>
|
||||
<td className="py-3 px-4">{duration.description}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={duration.isActive ? 'success' : 'danger'}>
|
||||
{duration.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasPermission('platforms:update') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('platforms:delete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Laufzeit wirklich löschen?')) {
|
||||
deleteMutation.mutate(duration.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Laufzeiten vorhanden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ContractDurationModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
duration={editingDuration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractDurationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
duration,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
duration: ContractDuration | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Reset form when modal opens or duration changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (duration) {
|
||||
setFormData({
|
||||
code: duration.code,
|
||||
description: duration.description,
|
||||
isActive: duration.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({ code: '', description: '', isActive: true });
|
||||
}
|
||||
}
|
||||
}, [isOpen, duration]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: contractDurationApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
|
||||
onClose();
|
||||
setFormData({ code: '', description: '', isActive: true });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ContractDuration>) =>
|
||||
contractDurationApi.update(duration!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (duration) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={duration ? 'Laufzeit bearbeiten' : 'Neue Laufzeit'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Code *"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
required
|
||||
placeholder="z.B. 12M, 24M, 2J"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Beschreibung *"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
required
|
||||
placeholder="z.B. 12 Monate, 24 Monate, 2 Jahre"
|
||||
/>
|
||||
|
||||
{duration && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { appSettingsApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function DeadlineSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settingsData, isLoading } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => appSettingsApi.getAll(),
|
||||
});
|
||||
|
||||
const [criticalDays, setCriticalDays] = useState('14');
|
||||
const [warningDays, setWarningDays] = useState('42');
|
||||
const [okDays, setOkDays] = useState('90');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData?.data) {
|
||||
setCriticalDays(settingsData.data.deadlineCriticalDays || '14');
|
||||
setWarningDays(settingsData.data.deadlineWarningDays || '42');
|
||||
setOkDays(settingsData.data.deadlineOkDays || '90');
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
// Validierung
|
||||
const critical = parseInt(criticalDays);
|
||||
const warning = parseInt(warningDays);
|
||||
const ok = parseInt(okDays);
|
||||
|
||||
if (isNaN(critical) || isNaN(warning) || isNaN(ok)) {
|
||||
alert('Bitte gültige Zahlen eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
if (critical >= warning || warning >= ok) {
|
||||
alert('Die Werte müssen aufsteigend sein: Kritisch < Warnung < OK');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
deadlineCriticalDays: criticalDays,
|
||||
deadlineWarningDays: warningDays,
|
||||
deadlineOkDays: okDays,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (setter: (value: string) => void, value: string) => {
|
||||
setter(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Fristenschwellen</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Farbkodierung für Fristen">
|
||||
<p className="text-gray-600 mb-6">
|
||||
Definiere, ab wann Vertragsfristen als kritisch (rot), Warnung (gelb) oder OK (grün)
|
||||
angezeigt werden sollen. Die Werte geben die Anzahl der Tage bis zur Frist an.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Kritisch (Rot) */}
|
||||
<div className="flex items-center gap-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block font-medium text-red-800 mb-1">
|
||||
Kritisch (Rot)
|
||||
</label>
|
||||
<p className="text-sm text-red-600 mb-2">
|
||||
Fristen mit weniger als X Tagen werden rot markiert
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={criticalDays}
|
||||
onChange={(e) => handleChange(setCriticalDays, e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-red-700">Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnung (Gelb) */}
|
||||
<div className="flex items-center gap-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block font-medium text-yellow-800 mb-1">
|
||||
Warnung (Gelb)
|
||||
</label>
|
||||
<p className="text-sm text-yellow-600 mb-2">
|
||||
Fristen mit weniger als X Tagen werden gelb markiert
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={warningDays}
|
||||
onChange={(e) => handleChange(setWarningDays, e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-yellow-700">Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OK (Grün) */}
|
||||
<div className="flex items-center gap-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<CheckCircle className="w-8 h-8 text-green-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block font-medium text-green-800 mb-1">
|
||||
OK (Grün)
|
||||
</label>
|
||||
<p className="text-sm text-green-600 mb-2">
|
||||
Fristen mit weniger als X Tagen werden grün markiert (darüber nicht angezeigt)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={okDays}
|
||||
onChange={(e) => handleChange(setOkDays, e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-green-700">Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Beispiel: Bei 14/42/90 Tagen wird eine Frist die in 10 Tagen abläuft rot,
|
||||
eine in 30 Tagen gelb, und eine in 60 Tagen grün markiert.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { emailProviderApi, EmailProviderConfig } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const PROVIDER_TYPES = [
|
||||
{ value: 'PLESK', label: 'Plesk' },
|
||||
{ value: 'CPANEL', label: 'cPanel' },
|
||||
{ value: 'DIRECTADMIN', label: 'DirectAdmin' },
|
||||
];
|
||||
|
||||
interface ProviderFormData {
|
||||
name: string;
|
||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
defaultForwardEmail: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: ProviderFormData = {
|
||||
name: '',
|
||||
type: 'PLESK',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
username: '',
|
||||
password: '',
|
||||
domain: 'stressfrei-wechseln.de',
|
||||
defaultForwardEmail: '',
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function EmailProviders() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<ProviderFormData>(emptyForm);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [modalTestResult, setModalTestResult] = useState<TestResult | null>(null);
|
||||
const [isTestingInModal, setIsTestingInModal] = useState(false);
|
||||
// Test-Status pro Provider in der Liste
|
||||
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
|
||||
const [testingProviderId, setTestingProviderId] = useState<number | null>(null);
|
||||
|
||||
const { data: configsData, isLoading } = useQuery({
|
||||
queryKey: ['email-provider-configs'],
|
||||
queryFn: () => emailProviderApi.getConfigs(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<EmailProviderConfig> & { password?: string }) =>
|
||||
emailProviderApi.createConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<EmailProviderConfig> & { password?: string } }) =>
|
||||
emailProviderApi.updateConfig(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const configs = configsData?.data || [];
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormData(emptyForm);
|
||||
setEditingId(null);
|
||||
setShowPassword(false);
|
||||
setModalTestResult(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (config: EmailProviderConfig) => {
|
||||
setFormData({
|
||||
name: config.name,
|
||||
type: config.type,
|
||||
apiUrl: config.apiUrl,
|
||||
apiKey: config.apiKey || '',
|
||||
username: config.username || '',
|
||||
password: '', // Passwort wird nicht geladen
|
||||
domain: config.domain,
|
||||
defaultForwardEmail: config.defaultForwardEmail || '',
|
||||
isActive: config.isActive,
|
||||
isDefault: config.isDefault,
|
||||
});
|
||||
setEditingId(config.id);
|
||||
setShowPassword(false);
|
||||
setModalTestResult(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingId(null);
|
||||
setFormData(emptyForm);
|
||||
setShowPassword(false);
|
||||
setModalTestResult(null);
|
||||
};
|
||||
|
||||
// Test für einen gespeicherten Provider in der Liste
|
||||
const handleTestProvider = async (config: EmailProviderConfig) => {
|
||||
setTestingProviderId(config.id);
|
||||
setProviderTestResults((prev) => ({ ...prev, [config.id]: null }));
|
||||
|
||||
try {
|
||||
// Provider per ID testen (Backend holt das gespeicherte Passwort)
|
||||
const result = await emailProviderApi.testConnection({ id: config.id });
|
||||
const testResult: TestResult = {
|
||||
success: result.data?.success || false,
|
||||
message: result.data?.message,
|
||||
error: result.data?.error,
|
||||
};
|
||||
setProviderTestResults((prev) => ({ ...prev, [config.id]: testResult }));
|
||||
} catch (error) {
|
||||
setProviderTestResults((prev) => ({
|
||||
...prev,
|
||||
[config.id]: {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Testen',
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setTestingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Test im Modal mit aktuellen Formulardaten
|
||||
const handleTestInModal = async () => {
|
||||
// Nur URL und Domain sind Pflicht - Auth-Fehler kommen vom Backend
|
||||
if (!formData.apiUrl || !formData.domain) {
|
||||
setModalTestResult({ success: false, error: 'Bitte geben Sie API-URL und Domain ein.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTestingInModal(true);
|
||||
setModalTestResult(null);
|
||||
try {
|
||||
const result = await emailProviderApi.testConnection({
|
||||
testData: {
|
||||
type: formData.type,
|
||||
apiUrl: formData.apiUrl,
|
||||
apiKey: formData.apiKey || undefined,
|
||||
username: formData.username || undefined,
|
||||
password: formData.password || undefined,
|
||||
domain: formData.domain,
|
||||
}
|
||||
});
|
||||
setModalTestResult({
|
||||
success: result.data?.success || false,
|
||||
message: result.data?.message,
|
||||
error: result.data?.error,
|
||||
});
|
||||
} catch (error) {
|
||||
setModalTestResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Verbindungstest'
|
||||
});
|
||||
} finally {
|
||||
setIsTestingInModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data: Partial<EmailProviderConfig> & { password?: string } = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
apiUrl: formData.apiUrl,
|
||||
apiKey: formData.apiKey, // Leerer String wird im Backend zu null
|
||||
username: formData.username,
|
||||
domain: formData.domain,
|
||||
defaultForwardEmail: formData.defaultForwardEmail,
|
||||
isActive: formData.isActive,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
|
||||
// Passwort nur senden wenn eingegeben
|
||||
if (formData.password) {
|
||||
data.password = formData.password;
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (confirm(`Möchten Sie den Provider "${name}" wirklich löschen?`)) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Fehlermeldung benutzerfreundlich formatieren
|
||||
const formatErrorMessage = (result: TestResult): string => {
|
||||
if (result.error) return result.error;
|
||||
if (result.message) return result.message;
|
||||
return 'Verbindung fehlgeschlagen';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/settings')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Zurück
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Email-Provisionierung</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
|
||||
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
|
||||
wird die E-Mail-Weiterleitung automatisch erstellt.
|
||||
</p>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Provider hinzufügen
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Laden...</div>
|
||||
) : configs.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Noch keine Email-Provider konfiguriert.
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{configs.map((config) => {
|
||||
const testResult = providerTestResults[config.id];
|
||||
const isTesting = testingProviderId === config.id;
|
||||
|
||||
return (
|
||||
<Card key={config.id}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">{config.name}</h3>
|
||||
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800">
|
||||
{config.type}
|
||||
</span>
|
||||
{config.isDefault && (
|
||||
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-800">
|
||||
Standard
|
||||
</span>
|
||||
)}
|
||||
{!config.isActive && (
|
||||
<span className="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">
|
||||
Inaktiv
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">API-URL</dt>
|
||||
<dd className="font-mono text-xs truncate">{config.apiUrl}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Domain</dt>
|
||||
<dd>{config.domain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Benutzer</dt>
|
||||
<dd>{config.username || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Standard-Weiterleitung</dt>
|
||||
<dd className="truncate">{config.defaultForwardEmail || '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Test-Ergebnis für diesen Provider */}
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
|
||||
{testResult.success ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Verbindung erfolgreich!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{formatErrorMessage(testResult)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestProvider(config)}
|
||||
disabled={isTesting}
|
||||
title="Verbindung testen"
|
||||
>
|
||||
{isTesting ? (
|
||||
<span className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditModal(config)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(config.id, config.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{editingId ? 'Provider bearbeiten' : 'Neuer Provider'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mutation Fehler anzeigen */}
|
||||
{(createMutation.error || updateMutation.error) && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-50 text-red-800 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<X className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: 'Fehler beim Speichern'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Name *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Plesk Hauptserver"
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Provider-Typ *"
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'PLESK' | 'CPANEL' | 'DIRECTADMIN' })}
|
||||
options={PROVIDER_TYPES}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="API-URL *"
|
||||
value={formData.apiUrl}
|
||||
onChange={(e) => setFormData({ ...formData, apiUrl: e.target.value })}
|
||||
placeholder="https://server.de:8443"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="API-Key"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Optional - alternativ zu Benutzername/Passwort"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="admin"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{editingId ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Domain *"
|
||||
value={formData.domain}
|
||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
||||
placeholder="stressfrei-wechseln.de"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Standard-Weiterleitungsadresse"
|
||||
value={formData.defaultForwardEmail}
|
||||
onChange={(e) => setFormData({ ...formData, defaultForwardEmail: e.target.value })}
|
||||
placeholder="info@meinefirma.de"
|
||||
type="email"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
Diese E-Mail-Adresse wird zusätzlich zur Kunden-E-Mail als Weiterleitungsziel hinzugefügt.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Aktiv</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isDefault}
|
||||
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Als Standard verwenden</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verbindungstest im Modal */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestInModal}
|
||||
disabled={isTestingInModal}
|
||||
className="w-full"
|
||||
>
|
||||
{isTestingInModal ? (
|
||||
'Teste Verbindung...'
|
||||
) : (
|
||||
<>
|
||||
<Wifi className="w-4 h-4 mr-2" />
|
||||
Verbindung testen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{modalTestResult && (
|
||||
<div className={`mt-2 p-3 rounded-lg text-sm ${modalTestResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
|
||||
{modalTestResult.success ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Verbindung erfolgreich!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{formatErrorMessage(modalTestResult)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={closeModal}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { appSettingsApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import { ArrowLeft, Globe, MessageSquare } from 'lucide-react';
|
||||
|
||||
export default function PortalSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settingsData, isLoading } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => appSettingsApi.getAll(),
|
||||
});
|
||||
|
||||
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData?.data) {
|
||||
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSupportToggle = (enabled: boolean) => {
|
||||
setCustomerSupportEnabled(enabled);
|
||||
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Kundenportal</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Support-Anfragen">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Kunden können Support-Anfragen erstellen</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Wenn aktiviert, können Kunden im Portal Support-Anfragen zu ihren Verträgen erstellen.
|
||||
Diese erscheinen als Aufgaben in der Vertragsdetailansicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={customerSupportEnabled}
|
||||
onChange={(e) => handleSupportToggle(e.target.checked)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{customerSupportEnabled && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Hinweis:</strong> Kunden sehen diese Anfragen als "Support-Anfragen" in ihrem Portal.
|
||||
Sie können die Anfrage mit einem Titel und einer Beschreibung erstellen.
|
||||
Ihre Mitarbeiter können dann mit Antworten (Unteraufgaben) reagieren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { providerApi, tariffApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, ArrowLeft, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Provider, Tariff } from '../../types';
|
||||
|
||||
export default function ProviderList() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<number>>(new Set());
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['providers', showInactive],
|
||||
queryFn: () => providerApi.getAll(showInactive),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: providerApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleExpanded = (providerId: number) => {
|
||||
setExpandedProviders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(providerId)) {
|
||||
next.delete(providerId);
|
||||
} else {
|
||||
next.add(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (provider: Provider) => {
|
||||
setEditingProvider(provider);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingProvider(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Anbieter & Tarife</h1>
|
||||
{hasPermission('providers:create') && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Anbieter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.data.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isExpanded={expandedProviders.has(provider.id)}
|
||||
onToggle={() => toggleExpanded(provider.id)}
|
||||
onEdit={() => handleEdit(provider)}
|
||||
onDelete={() => {
|
||||
if (confirm('Anbieter wirklich löschen?')) {
|
||||
deleteMutation.mutate(provider.id);
|
||||
}
|
||||
}}
|
||||
hasPermission={hasPermission}
|
||||
showInactive={showInactive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Anbieter vorhanden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ProviderModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
provider={editingProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
hasPermission,
|
||||
showInactive,
|
||||
}: {
|
||||
provider: Provider;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
showInactive: boolean;
|
||||
}) {
|
||||
const [showTariffModal, setShowTariffModal] = useState(false);
|
||||
const [editingTariff, setEditingTariff] = useState<Tariff | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteTariffMutation = useMutation({
|
||||
mutationFn: tariffApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const tariffs = provider.tariffs?.filter(t => showInactive || t.isActive) || [];
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center p-4 hover:bg-gray-50">
|
||||
<button onClick={onToggle} className="mr-3 p-1 hover:bg-gray-200 rounded">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
<Badge variant={provider.isActive ? 'success' : 'danger'}>
|
||||
{provider.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
({tariffs.length} Tarife, {provider._count?.contracts || 0} Verträge)
|
||||
</span>
|
||||
</div>
|
||||
{provider.portalUrl && (
|
||||
<a
|
||||
href={provider.portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{provider.portalUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{hasPermission('providers:update') && (
|
||||
<Button variant="ghost" size="sm" onClick={onEdit} title="Bearbeiten">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('providers:delete') && (
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} title="Löschen">
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-gray-50 p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="font-medium text-gray-700">Tarife</h4>
|
||||
{hasPermission('providers:create') && (
|
||||
<Button size="sm" onClick={() => setShowTariffModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Tarif hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{tariffs.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{tariffs.map((tariff) => (
|
||||
<div key={tariff.id} className="flex items-center justify-between bg-white p-3 rounded border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{tariff.name}</span>
|
||||
<Badge variant={tariff.isActive ? 'success' : 'danger'} className="text-xs">
|
||||
{tariff.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
{tariff._count?.contracts !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
({tariff._count.contracts} Verträge)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{hasPermission('providers:update') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTariff(tariff);
|
||||
setShowTariffModal(true);
|
||||
}}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('providers:delete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Tarif wirklich löschen?')) {
|
||||
deleteTariffMutation.mutate(tariff.id);
|
||||
}
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Keine Tarife vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TariffModal
|
||||
isOpen={showTariffModal}
|
||||
onClose={() => {
|
||||
setShowTariffModal(false);
|
||||
setEditingTariff(null);
|
||||
}}
|
||||
providerId={provider.id}
|
||||
tariff={editingTariff}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
provider: Provider | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
portalUrl: '',
|
||||
usernameFieldName: '',
|
||||
passwordFieldName: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (provider) {
|
||||
setFormData({
|
||||
name: provider.name,
|
||||
portalUrl: provider.portalUrl || '',
|
||||
usernameFieldName: provider.usernameFieldName || '',
|
||||
passwordFieldName: provider.passwordFieldName || '',
|
||||
isActive: provider.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
portalUrl: '',
|
||||
usernameFieldName: '',
|
||||
passwordFieldName: '',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, provider]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: providerApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<Provider>) =>
|
||||
providerApi.update(provider!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (provider) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={provider ? 'Anbieter bearbeiten' : 'Neuer Anbieter'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Anbietername *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="z.B. Vodafone, E.ON, Allianz"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Portal-URL (Login-Seite)"
|
||||
value={formData.portalUrl}
|
||||
onChange={(e) => setFormData({ ...formData, portalUrl: e.target.value })}
|
||||
placeholder="https://kundenportal.anbieter.de/login"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Auto-Login Felder</strong> (optional)<br />
|
||||
Feldnamen für URL-Parameter beim Auto-Login:
|
||||
</p>
|
||||
<Input
|
||||
label="Benutzername-Feldname"
|
||||
value={formData.usernameFieldName}
|
||||
onChange={(e) => setFormData({ ...formData, usernameFieldName: e.target.value })}
|
||||
placeholder="z.B. username, email, login"
|
||||
/>
|
||||
<Input
|
||||
label="Passwort-Feldname"
|
||||
value={formData.passwordFieldName}
|
||||
onChange={(e) => setFormData({ ...formData, passwordFieldName: e.target.value })}
|
||||
placeholder="z.B. password, pwd, kennwort"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TariffModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
providerId,
|
||||
tariff,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
providerId: number;
|
||||
tariff: Tariff | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (tariff) {
|
||||
setFormData({
|
||||
name: tariff.name,
|
||||
isActive: tariff.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({ name: '', isActive: true });
|
||||
}
|
||||
}
|
||||
}, [isOpen, tariff]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string }) => providerApi.createTariff(providerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<Tariff>) => tariffApi.update(tariff!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
alert(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (tariff) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={tariff ? 'Tarif bearbeiten' : 'Neuer Tarif'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Tarifname *"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="z.B. Comfort Plus, Basic 100"
|
||||
/>
|
||||
|
||||
{tariff && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppSettings } from '../../context/AppSettingsContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, Eye } from 'lucide-react';
|
||||
|
||||
const scrollThresholdOptions = [
|
||||
{ value: '0.1', label: '10%' },
|
||||
{ value: '0.2', label: '20%' },
|
||||
{ value: '0.3', label: '30%' },
|
||||
{ value: '0.4', label: '40%' },
|
||||
{ value: '0.5', label: '50%' },
|
||||
{ value: '0.6', label: '60%' },
|
||||
{ value: '0.7', label: '70% (Standard)' },
|
||||
{ value: '0.8', label: '80%' },
|
||||
{ value: '0.9', label: '90%' },
|
||||
{ value: '999', label: 'Deaktiviert' },
|
||||
];
|
||||
|
||||
export default function ViewSettings() {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Ansicht</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Scroll-Verhalten">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Nach-oben-Button</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Ab welcher Scroll-Position der Button unten rechts erscheinen soll
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Select
|
||||
options={scrollThresholdOptions}
|
||||
value={settings.scrollToTopThreshold.toString()}
|
||||
onChange={(e) => updateSettings({ scrollToTopThreshold: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { contractTaskApi, customerApi, contractApi, appSettingsApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Select from '../../components/ui/Select';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Input from '../../components/ui/Input';
|
||||
import {
|
||||
Eye,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
User,
|
||||
Users,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import type { ContractTask, ContractTaskStatus, Contract, Customer, ContractTaskSubtask } from '../../types';
|
||||
|
||||
const statusLabels: Record<ContractTaskStatus, string> = {
|
||||
OPEN: 'Offen',
|
||||
COMPLETED: 'Erledigt',
|
||||
};
|
||||
|
||||
const statusVariants: Record<ContractTaskStatus, 'warning' | 'success'> = {
|
||||
OPEN: 'warning',
|
||||
COMPLETED: 'success',
|
||||
};
|
||||
|
||||
export default function TaskList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { isCustomerPortal, user, hasPermission } = useAuth();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('OPEN');
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||
|
||||
// Labels abhängig von Benutzertyp
|
||||
const pageTitle = isCustomerPortal ? 'Support-Anfragen' : 'Aufgaben';
|
||||
const taskLabel = isCustomerPortal ? 'Anfrage' : 'Aufgabe';
|
||||
|
||||
// Lade öffentliche Einstellungen (für Kundenportal - Support-Tickets aktiviert?)
|
||||
const { data: publicSettings, isLoading: isLoadingSettings } = useQuery({
|
||||
queryKey: ['app-settings-public'],
|
||||
queryFn: () => appSettingsApi.getPublic(),
|
||||
enabled: isCustomerPortal,
|
||||
staleTime: 0, // Immer neu laden, damit Einstellungsänderungen sofort wirken
|
||||
});
|
||||
|
||||
// Wichtig: Nur true wenn explizit aktiviert UND geladen
|
||||
const supportTicketsEnabled = !isLoadingSettings && publicSettings?.data?.customerSupportTicketsEnabled === 'true';
|
||||
|
||||
const { data: tasksData, isLoading } = useQuery({
|
||||
queryKey: ['all-tasks', statusFilter],
|
||||
queryFn: () => contractTaskApi.getAll({
|
||||
status: statusFilter as ContractTaskStatus || undefined
|
||||
}),
|
||||
staleTime: 0, // Immer neu laden, damit Änderungen sofort sichtbar sind
|
||||
});
|
||||
|
||||
// Mutations für Subtasks
|
||||
const completeSubtaskMutation = useMutation({
|
||||
mutationFn: (subtaskId: number) => contractTaskApi.completeSubtask(subtaskId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const reopenSubtaskMutation = useMutation({
|
||||
mutationFn: (subtaskId: number) => contractTaskApi.reopenSubtask(subtaskId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createSubtaskMutation = useMutation({
|
||||
mutationFn: ({ taskId, title }: { taskId: number; title: string }) =>
|
||||
isCustomerPortal
|
||||
? contractTaskApi.createReply(taskId, title)
|
||||
: contractTaskApi.createSubtask(taskId, title),
|
||||
onSuccess: (_, { taskId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
setReplyInputs(prev => ({ ...prev, [taskId]: '' }));
|
||||
},
|
||||
});
|
||||
|
||||
// Gruppiere Tasks nach Vertrag für Kundenportal
|
||||
const groupedTasks = useMemo(() => {
|
||||
if (!tasksData?.data) return { ownTasks: [], representedTasks: [], allTasks: [] };
|
||||
|
||||
const tasks = tasksData.data;
|
||||
|
||||
if (!isCustomerPortal) {
|
||||
// Für Mitarbeiter: Alle Tasks in einer Liste
|
||||
return { allTasks: tasks, ownTasks: [], representedTasks: [] };
|
||||
}
|
||||
|
||||
// Für Kundenportal: Nach eigenen vs. freigegebenen Kunden gruppieren
|
||||
const ownTasks: ContractTask[] = [];
|
||||
const representedTasks: ContractTask[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.contract?.customerId === user?.customerId) {
|
||||
ownTasks.push(task);
|
||||
} else {
|
||||
representedTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
return { ownTasks, representedTasks, allTasks: [] };
|
||||
}, [tasksData?.data, isCustomerPortal, user?.customerId]);
|
||||
|
||||
const toggleExpanded = (taskId: number) => {
|
||||
setExpandedTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
} else {
|
||||
next.add(taskId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubtaskToggle = (subtask: ContractTaskSubtask) => {
|
||||
// Verhindere Doppelklicks während Mutation läuft
|
||||
if (completeSubtaskMutation.isPending || reopenSubtaskMutation.isPending) return;
|
||||
|
||||
if (subtask.status === 'COMPLETED') {
|
||||
reopenSubtaskMutation.mutate(subtask.id);
|
||||
} else {
|
||||
completeSubtaskMutation.mutate(subtask.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddReply = (taskId: number) => {
|
||||
const title = replyInputs[taskId]?.trim();
|
||||
if (!title) return;
|
||||
createSubtaskMutation.mutate({ taskId, title });
|
||||
};
|
||||
|
||||
// Prüfe ob Benutzer Subtasks bearbeiten kann
|
||||
const canEditSubtasks = !isCustomerPortal && hasPermission('contracts:update');
|
||||
|
||||
const renderTask = (task: ContractTask, showCustomer = false) => {
|
||||
const isExpanded = expandedTasks.has(task.id);
|
||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||
const completedSubtasks = task.subtasks?.filter(s => s.status === 'COMPLETED').length || 0;
|
||||
const totalSubtasks = task.subtasks?.length || 0;
|
||||
const isTaskCompleted = task.status === 'COMPLETED';
|
||||
|
||||
const contractDisplay = task.contract
|
||||
? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}`
|
||||
: `Vertrag #${task.contractId}`;
|
||||
|
||||
const customerDisplay = task.contract?.customer
|
||||
? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={task.id} className="border rounded-lg mb-2">
|
||||
{/* Task Header */}
|
||||
<div
|
||||
className="flex items-center p-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleExpanded(task.id)}
|
||||
>
|
||||
{/* Expand Button */}
|
||||
<div className="w-6 mr-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className="mr-3">
|
||||
{task.status === 'COMPLETED' ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<Clock className="w-5 h-5 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{task.title}</span>
|
||||
<Badge variant={statusVariants[task.status]}>
|
||||
{statusLabels[task.status]}
|
||||
</Badge>
|
||||
{hasSubtasks && (
|
||||
<span className="text-xs text-gray-500">
|
||||
({completedSubtasks}/{totalSubtasks} erledigt)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<Link
|
||||
to={`/contracts/${task.contractId}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contractDisplay}
|
||||
</Link>
|
||||
{showCustomer && customerDisplay && (
|
||||
<>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span>{customerDisplay}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="ml-4 flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/contracts/${task.contractId}`);
|
||||
}}
|
||||
title="Zum Vertrag"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content: Subtasks + Reply */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-gray-50 px-4 py-3">
|
||||
{/* Subtasks */}
|
||||
{hasSubtasks && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{task.subtasks?.map((subtask) => {
|
||||
const createdDate = new Date(subtask.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`flex items-start gap-2 text-sm ml-6 ${canEditSubtasks ? 'cursor-pointer hover:bg-gray-100 rounded px-2 py-1 -mx-2' : ''}`}
|
||||
onClick={canEditSubtasks ? () => handleSubtaskToggle(subtask) : undefined}
|
||||
>
|
||||
{/* Radio-Button Style Icon */}
|
||||
<span className="flex-shrink-0 mt-0.5">
|
||||
{subtask.status === 'COMPLETED' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
<span className={subtask.status === 'COMPLETED' ? 'text-gray-500 line-through' : ''}>
|
||||
{subtask.title}
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
{subtask.createdBy} • {createdDate}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply Input - nur anzeigen wenn Task noch offen */}
|
||||
{!isTaskCompleted && (canEditSubtasks || isCustomerPortal) && (
|
||||
<div className="flex gap-2 ml-6">
|
||||
<Input
|
||||
placeholder={isCustomerPortal ? 'Antwort schreiben...' : 'Neue Unteraufgabe...'}
|
||||
value={replyInputs[task.id] || ''}
|
||||
onChange={(e) => setReplyInputs(prev => ({ ...prev, [task.id]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAddReply(task.id);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddReply(task.id)}
|
||||
disabled={!replyInputs[task.id]?.trim() || createSubtaskMutation.isPending}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Subtasks Nachricht */}
|
||||
{!hasSubtasks && isTaskCompleted && (
|
||||
<p className="text-gray-500 text-sm text-center py-2">
|
||||
Keine Unteraufgaben vorhanden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Kann der Benutzer Aufgaben erstellen?
|
||||
// Für Kundenportal: Nur wenn Setting aktiviert UND geladen ist
|
||||
const canCreateTask = isCustomerPortal
|
||||
? supportTicketsEnabled
|
||||
: hasPermission('contracts:update');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">{pageTitle}</h1>
|
||||
{canCreateTask && (
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neue {taskLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex gap-4 flex-wrap items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Status:</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Alle' },
|
||||
...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))
|
||||
]}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Kundenportal: Gruppierte Ansicht */}
|
||||
{isCustomerPortal ? (
|
||||
<div className="space-y-6">
|
||||
{/* Eigene Anfragen */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Meine {pageTitle}</h2>
|
||||
<Badge variant="default">{groupedTasks.ownTasks.length}</Badge>
|
||||
</div>
|
||||
{groupedTasks.ownTasks.length > 0 ? (
|
||||
<div>
|
||||
{groupedTasks.ownTasks.map((task) => renderTask(task, false))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
Keine eigenen {pageTitle.toLowerCase()} vorhanden.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Fremd-Anfragen - nur anzeigen wenn vorhanden */}
|
||||
{groupedTasks.representedTasks.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{pageTitle} freigegebener Kunden
|
||||
</h2>
|
||||
<Badge variant="default">{groupedTasks.representedTasks.length}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
{groupedTasks.representedTasks.map((task) => renderTask(task, true))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Mitarbeiter-Ansicht */
|
||||
<Card>
|
||||
{groupedTasks.allTasks && groupedTasks.allTasks.length > 0 ? (
|
||||
<div>
|
||||
{groupedTasks.allTasks.map((task) => renderTask(task, true))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Keine {pageTitle.toLowerCase()} gefunden.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal für neue Aufgabe/Support-Anfrage */}
|
||||
{isCustomerPortal ? (
|
||||
<CreateSupportTicketModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
) : (
|
||||
<CreateTaskModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal für neue Support-Anfrage (Kundenportal)
|
||||
function CreateSupportTicketModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [customerFilter, setCustomerFilter] = useState<'own' | number>('own');
|
||||
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [contractSearch, setContractSearch] = useState('');
|
||||
|
||||
// Lade alle Verträge des Benutzers (eigene + freigegebene)
|
||||
const { data: contractsData } = useQuery({
|
||||
queryKey: ['contracts', user?.customerId],
|
||||
queryFn: () => contractApi.getAll({ customerId: user?.customerId }),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Gruppiere Verträge nach Kunde
|
||||
const groupedContracts = useMemo(() => {
|
||||
if (!contractsData?.data) return { own: [], represented: {} as Record<number, { name: string; contracts: Contract[] }> };
|
||||
|
||||
const own: Contract[] = [];
|
||||
const represented: Record<number, { name: string; contracts: Contract[] }> = {};
|
||||
|
||||
for (const contract of contractsData.data) {
|
||||
if (contract.customerId === user?.customerId) {
|
||||
own.push(contract);
|
||||
} else {
|
||||
if (!represented[contract.customerId]) {
|
||||
const name = contract.customer
|
||||
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
|
||||
: `Kunde ${contract.customerId}`;
|
||||
represented[contract.customerId] = { name, contracts: [] };
|
||||
}
|
||||
represented[contract.customerId].contracts.push(contract);
|
||||
}
|
||||
}
|
||||
|
||||
return { own, represented };
|
||||
}, [contractsData?.data, user?.customerId]);
|
||||
|
||||
// Hat der Benutzer freigegebene Kunden?
|
||||
const hasRepresentedCustomers = Object.keys(groupedContracts.represented).length > 0;
|
||||
|
||||
// Aktuelle Verträge basierend auf Kundenfilter
|
||||
const currentContracts = useMemo(() => {
|
||||
if (customerFilter === 'own') {
|
||||
return groupedContracts.own;
|
||||
}
|
||||
return groupedContracts.represented[customerFilter]?.contracts || [];
|
||||
}, [customerFilter, groupedContracts]);
|
||||
|
||||
// Gefilterte Verträge basierend auf Suche
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!contractSearch) return currentContracts;
|
||||
const search = contractSearch.toLowerCase();
|
||||
return currentContracts.filter(c =>
|
||||
c.contractNumber.toLowerCase().includes(search) ||
|
||||
(c.providerName || '').toLowerCase().includes(search) ||
|
||||
(c.tariffName || '').toLowerCase().includes(search)
|
||||
);
|
||||
}, [currentContracts, contractSearch]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedContractId || !title.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await contractTaskApi.createSupportTicket(selectedContractId, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||
onClose();
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSelectedContractId(null);
|
||||
setCustomerFilter('own');
|
||||
// Navigate to the contract
|
||||
navigate(`/contracts/${selectedContractId}`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Support-Anfrage:', error);
|
||||
alert('Fehler beim Erstellen der Support-Anfrage. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSelectedContractId(null);
|
||||
setCustomerFilter('own');
|
||||
setContractSearch('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Neue Support-Anfrage"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Kundenauswahl (nur wenn freigegebene Kunden vorhanden) */}
|
||||
{hasRepresentedCustomers && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
value={customerFilter}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setCustomerFilter(val === 'own' ? 'own' : parseInt(val));
|
||||
setSelectedContractId(null);
|
||||
setContractSearch('');
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="own">Eigene Verträge</option>
|
||||
{Object.entries(groupedContracts.represented).map(([id, { name }]) => (
|
||||
<option key={id} value={id}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vertragsauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vertrag *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Vertrag suchen..."
|
||||
value={contractSearch}
|
||||
onChange={(e) => setContractSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg">
|
||||
{filteredContracts.length > 0 ? (
|
||||
filteredContracts.map((contract) => (
|
||||
<div
|
||||
key={contract.id}
|
||||
onClick={() => setSelectedContractId(contract.id)}
|
||||
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
|
||||
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{contract.contractNumber}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{contract.providerName || 'Kein Anbieter'}
|
||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-gray-500 text-center">
|
||||
Keine Verträge gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Titel */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel *
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Kurze Beschreibung Ihres Anliegens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Detaillierte Beschreibung (optional)"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedContractId || !title.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Wird erstellt...' : 'Anfrage erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal für neue Aufgabe (Mitarbeiter/Admin)
|
||||
function CreateTaskModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
|
||||
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [visibleInPortal, setVisibleInPortal] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [contractSearch, setContractSearch] = useState('');
|
||||
|
||||
// Lade Kunden
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-for-task'],
|
||||
queryFn: () => customerApi.getAll({ limit: 100 }),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Lade Verträge des ausgewählten Kunden
|
||||
const { data: contractsData } = useQuery({
|
||||
queryKey: ['contracts-for-task', selectedCustomerId],
|
||||
queryFn: () => contractApi.getAll({ customerId: selectedCustomerId! }),
|
||||
enabled: isOpen && selectedCustomerId !== null,
|
||||
});
|
||||
|
||||
// Gefilterte Kunden
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!customersData?.data) return [];
|
||||
if (!customerSearch) return customersData.data;
|
||||
const search = customerSearch.toLowerCase();
|
||||
return customersData.data.filter(c =>
|
||||
c.customerNumber.toLowerCase().includes(search) ||
|
||||
c.firstName.toLowerCase().includes(search) ||
|
||||
c.lastName.toLowerCase().includes(search) ||
|
||||
(c.companyName || '').toLowerCase().includes(search)
|
||||
);
|
||||
}, [customersData?.data, customerSearch]);
|
||||
|
||||
// Gefilterte Verträge
|
||||
const filteredContracts = useMemo(() => {
|
||||
if (!contractsData?.data) return [];
|
||||
if (!contractSearch) return contractsData.data;
|
||||
const search = contractSearch.toLowerCase();
|
||||
return contractsData.data.filter(c =>
|
||||
c.contractNumber.toLowerCase().includes(search) ||
|
||||
(c.providerName || '').toLowerCase().includes(search) ||
|
||||
(c.tariffName || '').toLowerCase().includes(search)
|
||||
);
|
||||
}, [contractsData?.data, contractSearch]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedContractId || !title.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await contractTaskApi.create(selectedContractId, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
visibleInPortal,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||
onClose();
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setVisibleInPortal(false);
|
||||
setSelectedContractId(null);
|
||||
setSelectedCustomerId(null);
|
||||
// Navigate to the contract
|
||||
navigate(`/contracts/${selectedContractId}`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Aufgabe:', error);
|
||||
alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setVisibleInPortal(false);
|
||||
setSelectedContractId(null);
|
||||
setSelectedCustomerId(null);
|
||||
setCustomerSearch('');
|
||||
setContractSearch('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getCustomerDisplay = (customer: Customer) => {
|
||||
const name = customer.companyName || `${customer.firstName} ${customer.lastName}`;
|
||||
return `${customer.customerNumber} - ${name}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Neue Aufgabe"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Kundenauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kunde *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Kunde suchen..."
|
||||
value={customerSearch}
|
||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="max-h-40 overflow-y-auto border rounded-lg">
|
||||
{filteredCustomers.length > 0 ? (
|
||||
filteredCustomers.map((customer) => (
|
||||
<div
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
setSelectedCustomerId(customer.id);
|
||||
setSelectedContractId(null);
|
||||
setContractSearch('');
|
||||
}}
|
||||
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
|
||||
selectedCustomerId === customer.id ? 'bg-blue-50 border-blue-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{getCustomerDisplay(customer)}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-gray-500 text-center">
|
||||
Keine Kunden gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
|
||||
{selectedCustomerId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vertrag *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Vertrag suchen..."
|
||||
value={contractSearch}
|
||||
onChange={(e) => setContractSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="max-h-40 overflow-y-auto border rounded-lg">
|
||||
{filteredContracts.length > 0 ? (
|
||||
filteredContracts.map((contract) => (
|
||||
<div
|
||||
key={contract.id}
|
||||
onClick={() => setSelectedContractId(contract.id)}
|
||||
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
|
||||
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{contract.contractNumber}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{contract.providerName || 'Kein Anbieter'}
|
||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-gray-500 text-center">
|
||||
{contractsData ? 'Keine Verträge gefunden.' : 'Laden...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Titel */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel *
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Aufgabentitel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Detaillierte Beschreibung (optional)"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Im Kundenportal sichtbar */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleInPortal}
|
||||
onChange={(e) => setVisibleInPortal(e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedContractId || !title.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userApi } from '../../services/api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { User, Role } from '../../types';
|
||||
|
||||
export default function UserList() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { refreshUser } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', search, page],
|
||||
queryFn: () => userApi.getAll({ search: search || undefined, page, limit: 20 }),
|
||||
});
|
||||
|
||||
const { data: rolesData } = useQuery({
|
||||
queryKey: ['roles'],
|
||||
queryFn: () => userApi.getRoles(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: userApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error?.message || 'Fehler beim Löschen des Benutzers');
|
||||
},
|
||||
});
|
||||
|
||||
// Check if user is admin (has Admin role)
|
||||
const isUserAdmin = (user: User) => {
|
||||
return user.roles?.some((role: any) => role.name === 'Admin');
|
||||
};
|
||||
|
||||
// Count active admins
|
||||
const activeAdminCount = data?.data?.filter(
|
||||
(u) => (u as any).isActive && isUserAdmin(u)
|
||||
).length || 0;
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold flex-1">Benutzer</h1>
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">E-Mail</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Rollen</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.map((user) => (
|
||||
<tr key={user.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="py-3 px-4">{user.email}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{user.roles?.filter((role: any) => role.name !== 'Developer').map((role: any) => (
|
||||
<Badge key={role.id || role.name} variant="info">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={(user as any).isActive ? 'success' : 'danger'}>
|
||||
{(user as any).isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
{(user as any).hasDeveloperAccess && (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<Code className="w-3 h-3" />
|
||||
Dev
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(user)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const isLastAdmin = isUserAdmin(user) && (user as any).isActive && activeAdminCount <= 1;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isLastAdmin}
|
||||
title={isLastAdmin ? 'Letzter Administrator kann nicht gelöscht werden' : undefined}
|
||||
onClick={() => {
|
||||
if (confirm('Benutzer wirklich löschen?')) {
|
||||
deleteMutation.mutate(user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className={`w-4 h-4 ${isLastAdmin ? 'text-gray-300' : 'text-red-500'}`} />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.pagination && data.pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Seite {data.pagination.page} von {data.pagination.totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page >= data.pagination.totalPages}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Benutzer gefunden.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<UserModal
|
||||
isOpen={showModal}
|
||||
onClose={handleClose}
|
||||
user={editingUser}
|
||||
roles={rolesData?.data || []}
|
||||
onUserUpdated={refreshUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
roles,
|
||||
onUserUpdated,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: User | null;
|
||||
roles: Role[];
|
||||
onUserUpdated: () => Promise<void>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
roleIds: [] as number[],
|
||||
isActive: true,
|
||||
hasDeveloperAccess: false,
|
||||
});
|
||||
|
||||
// Reset form when modal opens or user changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setError(null);
|
||||
if (user) {
|
||||
setFormData({
|
||||
email: user.email,
|
||||
password: '',
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
roleIds: user.roles?.filter((r: any) => r.name !== 'Developer').map((r: any) => r.id) || [],
|
||||
isActive: (user as any).isActive ?? true,
|
||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
roleIds: [],
|
||||
isActive: true,
|
||||
hasDeveloperAccess: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: userApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.message || 'Fehler beim Erstellen des Benutzers');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => userApi.update(user!.id, data),
|
||||
onSuccess: async () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
await onUserUpdated(); // Refresh current user's permissions and wait for it
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.message || 'Fehler beim Aktualisieren des Benutzers');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (user) {
|
||||
const updateData: any = {
|
||||
email: formData.email,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
roleIds: formData.roleIds,
|
||||
isActive: formData.isActive,
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
updateMutation.mutate(updateData);
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
roleIds: formData.roleIds,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleToggle = (roleId: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
roleIds: prev.roleIds.includes(roleId)
|
||||
? prev.roleIds.filter((id) => id !== roleId)
|
||||
: [...prev.roleIds, roleId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={user ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Vorname *"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Nachname *"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="E-Mail *"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={user ? 'Neues Passwort (leer = unverändert)' : 'Passwort *'}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!user}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||
<div className="space-y-2">
|
||||
{roles.filter((role) => role.name !== 'Developer').map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.roleIds.includes(role.id)}
|
||||
onChange={() => handleRoleToggle(role.id)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
{role.description && (
|
||||
<span className="text-sm text-gray-500">({role.description})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasDeveloperAccess}
|
||||
onChange={(e) => setFormData({ ...formData, hasDeveloperAccess: e.target.checked })}
|
||||
className="rounded border-purple-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="flex items-center gap-1">
|
||||
<Code className="w-4 h-4 text-purple-600" />
|
||||
Entwicklerzugriff
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">(Datenbanktools)</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user