Files
opencrm/frontend/src/pages/Dashboard.tsx
T
Stefan Hacker e209e9bbca first commit
2026-01-29 01:16:54 +01:00

627 lines
23 KiB
TypeScript

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 (&lt;{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 (&lt;{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 (&lt;{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>
);
}