first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user