6a670df1c4
Bug 1 — Support-Anfrage: ausgewaehlter Vertrag nicht erkennbar
Im Kundenportal beim Erstellen einer Support-Anfrage war der
Selected-State des Vertrags nur ein dezenter blau-grauer
Hintergrund + Border-Farbwechsel. Auf hellem Bildschirm / nicht-
perfekter Lichtsituation kaum zu sehen.
Fix: kraefigere Markierung mit linkem 4px-Akzent-Bar
(border-l-blue-600), kraefigerem Background (bg-blue-100),
Checkmark-Icon rechtsbuendig und blauer Titel-Text.
Bug 2 — Email-Sync im Portal: "Keine Berechtigung"
POST /api/stressfrei-emails/:id/sync hatte
requirePermission('customers:update') – die Portal-Kunden nicht
haben (nur customers:read fuer eigene Daten). Sie konnten ihr
eigenes Postfach nicht synchronisieren.
Fix: Perm-Middleware aus der Route raus, Mitarbeiter-Check +
Owner-Check in den Controller verlegt:
- isCustomerPortal: nur Owner-Check (canAccessStressfreiEmail)
- Mitarbeiter: muss customers:update haben
Trennung der Threat-Modelle – Portal-User darf sein Postfach
syncen, sonst aber nichts triggern; Mitarbeiter brauchen weiter
die Update-Perm.
Live-verifiziert:
- Portal-User 1 syncs eigenes Konto → Auth passiert (400 wegen
fehlender IMAP-Config in dev-DB, NICHT 403)
- Portal-User 1 syncs Customer-3-Konto → 403 "Kein Zugriff"
- Mitarbeiter ohne customers:update → weiter 403
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1061 lines
37 KiB
TypeScript
1061 lines
37 KiB
TypeScript
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,
|
|
Edit,
|
|
Trash2,
|
|
} 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>>({});
|
|
const [editingTask, setEditingTask] = useState<ContractTask | null>(null);
|
|
|
|
// 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]: '' }));
|
|
},
|
|
});
|
|
|
|
const deleteTaskMutation = useMutation({
|
|
mutationFn: (taskId: number) => contractTaskApi.delete(taskId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
|
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
|
},
|
|
});
|
|
|
|
const updateTaskMutation = useMutation({
|
|
mutationFn: ({ taskId, data }: { taskId: number; data: { title?: string; description?: string; visibleInPortal?: boolean } }) =>
|
|
contractTaskApi.update(taskId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
|
setEditingTask(null);
|
|
},
|
|
});
|
|
|
|
// 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';
|
|
|
|
// Datum+Uhrzeit für Tasks
|
|
const taskCreatedDateTime = new Date(task.createdAt).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
|
|
// Mitarbeiter können eigene Aufgaben bearbeiten/löschen
|
|
// Kunden können Support-Tickets NICHT löschen/bearbeiten
|
|
const canEditTask = !isCustomerPortal && hasPermission('contracts:update');
|
|
const canDeleteTask = !isCustomerPortal && hasPermission('contracts:update');
|
|
|
|
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>
|
|
)}
|
|
{/* Ersteller und Datum */}
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
{task.createdBy} • {taskCreatedDateTime}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="ml-4 flex gap-2">
|
|
{canEditTask && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingTask(task);
|
|
}}
|
|
title="Bearbeiten"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
{canDeleteTask && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (confirm('Aufgabe wirklich löschen?')) {
|
|
deleteTaskMutation.mutate(task.id);
|
|
}
|
|
}}
|
|
title="Löschen"
|
|
className="text-red-500 hover:text-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
<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 createdDateTime = new Date(subtask.createdAt).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
|
|
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} • {createdDateTime}
|
|
</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)}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal zum Bearbeiten einer Aufgabe */}
|
|
{editingTask && (
|
|
<EditTaskModal
|
|
task={editingTask}
|
|
onClose={() => setEditingTask(null)}
|
|
onSave={(data) => updateTaskMutation.mutate({ taskId: editingTask.id, data })}
|
|
isPending={updateTaskMutation.isPending}
|
|
/>
|
|
)}
|
|
</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) => {
|
|
const isSelected = selectedContractId === contract.id;
|
|
return (
|
|
<div
|
|
key={contract.id}
|
|
onClick={() => setSelectedContractId(contract.id)}
|
|
className={`p-3 cursor-pointer border-b last:border-b-0 transition-colors flex items-center gap-3 ${
|
|
isSelected
|
|
? 'bg-blue-100 border-l-4 border-l-blue-600 pl-2'
|
|
: 'hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{isSelected && (
|
|
<CheckCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`font-medium ${isSelected ? 'text-blue-900' : ''}`}>
|
|
{contract.contractNumber}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{contract.providerName || 'Kein Anbieter'}
|
|
{contract.tariffName && ` - ${contract.tariffName}`}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// Modal zum Bearbeiten einer Aufgabe
|
|
function EditTaskModal({
|
|
task,
|
|
onClose,
|
|
onSave,
|
|
isPending,
|
|
}: {
|
|
task: ContractTask;
|
|
onClose: () => void;
|
|
onSave: (data: { title?: string; description?: string; visibleInPortal?: boolean }) => void;
|
|
isPending: boolean;
|
|
}) {
|
|
const [title, setTitle] = useState(task.title);
|
|
const [description, setDescription] = useState(task.description || '');
|
|
const [visibleInPortal, setVisibleInPortal] = useState(task.visibleInPortal || false);
|
|
|
|
const handleSubmit = () => {
|
|
if (!title.trim()) return;
|
|
onSave({
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
visibleInPortal,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={true} onClose={onClose} title="Aufgabe bearbeiten">
|
|
<div className="space-y-4">
|
|
{/* 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={onClose}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={!title.trim() || isPending}>
|
|
{isPending ? 'Wird gespeichert...' : 'Speichern'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|