Aufgaben ohne Kunde/Vertrag anlegbar

ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).

Task-Modal (Mitarbeiter): Checkbox "Ohne Kunde (allgemeine Aufgabe)" blendet
Kunden-/Vertragsauswahl UND "Im Kundenportal sichtbar" aus. Task-Liste zeigt
solche Aufgaben als "Allgemeine Aufgabe (ohne Vertrag)" ohne Vertrags-Link/
Zum-Vertrag-Button.

Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:15:33 +02:00
co-authored by Claude Opus 4.8
parent b8128616f2
commit 15142676ef
9 changed files with 161 additions and 35 deletions
+71 -29
View File
@@ -196,7 +196,7 @@ export default function TaskList() {
const contractDisplay = task.contract
? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}`
: `Vertrag #${task.contractId}`;
: (task.contractId != null ? `Vertrag #${task.contractId}` : 'Allgemeine Aufgabe (ohne Vertrag)');
const customerDisplay = task.contract?.customer
? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`)
@@ -242,13 +242,17 @@ export default function TaskList() {
</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>
{task.contractId != null ? (
<Link
to={`/contracts/${task.contractId}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contractDisplay}
</Link>
) : (
<span className="text-gray-500 italic">{contractDisplay}</span>
)}
{showCustomer && customerDisplay && (
<>
<span className="text-gray-400">|</span>
@@ -296,17 +300,19 @@ export default function TaskList() {
<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>
{task.contractId != null && (
<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>
@@ -763,6 +769,7 @@ function CreateTaskModal({
const queryClient = useQueryClient();
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const [noCustomer, setNoCustomer] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [visibleInPortal, setVisibleInPortal] = useState(false);
@@ -810,15 +817,25 @@ function CreateTaskModal({
}, [contractsData?.data, contractSearch]);
const handleSubmit = async () => {
if (!selectedContractId || !title.trim()) return;
if (!title.trim()) return;
if (!noCustomer && !selectedContractId) return;
const contractId = selectedContractId;
setIsSubmitting(true);
try {
await contractTaskApi.create(selectedContractId, {
title: title.trim(),
description: description.trim() || undefined,
visibleInPortal,
});
if (noCustomer) {
// Allgemeine Aufgabe ohne Vertrag/Kunde nie im Portal sichtbar.
await contractTaskApi.createGeneral({
title: title.trim(),
description: description.trim() || undefined,
});
} else {
await contractTaskApi.create(contractId!, {
title: title.trim(),
description: description.trim() || undefined,
visibleInPortal,
});
}
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
onClose();
@@ -828,8 +845,9 @@ function CreateTaskModal({
setVisibleInPortal(false);
setSelectedContractId(null);
setSelectedCustomerId(null);
// Navigate to the contract
navigate(`/contracts/${selectedContractId}`);
setNoCustomer(false);
// Bei vertragsgebundener Aufgabe zum Vertrag springen.
if (!noCustomer && contractId) navigate(`/contracts/${contractId}`);
} catch (error) {
console.error('Fehler beim Erstellen der Aufgabe:', error);
alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.');
@@ -844,6 +862,7 @@ function CreateTaskModal({
setVisibleInPortal(false);
setSelectedContractId(null);
setSelectedCustomerId(null);
setNoCustomer(false);
setCustomerSearch('');
setContractSearch('');
onClose();
@@ -861,7 +880,27 @@ function CreateTaskModal({
title="Neue Aufgabe"
>
<div className="space-y-4">
{/* Ohne Kunde (allgemeine Aufgabe) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={noCustomer}
onChange={(e) => {
setNoCustomer(e.target.checked);
if (e.target.checked) {
// Ohne Kunde → Kunden-/Vertragsauswahl + Portal-Sichtbarkeit weg.
setSelectedCustomerId(null);
setSelectedContractId(null);
setVisibleInPortal(false);
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Ohne Kunde (allgemeine Aufgabe)</span>
</label>
{/* Kundenauswahl */}
{!noCustomer && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunde *
@@ -896,6 +935,7 @@ function CreateTaskModal({
)}
</div>
</div>
)}
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
{selectedCustomerId && (
@@ -969,7 +1009,8 @@ function CreateTaskModal({
/>
</div>
{/* Im Kundenportal sichtbar */}
{/* Im Kundenportal sichtbar nur wenn ein Kunde/Vertrag zugeordnet ist */}
{!noCustomer && (
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -981,6 +1022,7 @@ function CreateTaskModal({
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
</label>
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4">
@@ -989,7 +1031,7 @@ function CreateTaskModal({
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContractId || !title.trim() || isSubmitting}
disabled={(!noCustomer && !selectedContractId) || !title.trim() || isSubmitting}
>
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
</Button>
+5
View File
@@ -1072,6 +1072,11 @@ export const contractTaskApi = {
const res = await api.post<ApiResponse<ContractTask>>(`/contracts/${contractId}/tasks`, data);
return res.data;
},
// Allgemeine Aufgabe ohne Vertrag/Kunde (nur Mitarbeiter).
createGeneral: async (data: { title: string; description?: string }) => {
const res = await api.post<ApiResponse<ContractTask>>(`/tasks`, data);
return res.data;
},
update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => {
const res = await api.put<ApiResponse<ContractTask>>(`/tasks/${taskId}`, data);
return res.data;
+1 -1
View File
@@ -471,7 +471,7 @@ export interface ContractTaskContract {
export interface ContractTask {
id: number;
contractId: number;
contractId: number | null;
title: string;
description?: string;
status: ContractTaskStatus;