diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 07fe188f..c73b3288 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -2002,8 +2002,9 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon }, }); - // Falls Lieferbestätigung + Vertrag DRAFT → automatisch auf ACTIVE - await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req); + // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer + const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null; + await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate); res.json({ success: true, data: doc } as ApiResponse); } catch (error) { diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index 5c23a0e8..feb75dbb 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -462,7 +462,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro export async function uploadContractDocument(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); - const { documentType, notes } = req.body; + const { documentType, notes, deliveryDate } = req.body; if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse); @@ -495,8 +495,8 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P customerId: contract?.customerId, }); - // Falls Lieferbestätigung + Vertrag DRAFT → automatisch auf ACTIVE - await maybeActivateOnDeliveryConfirmation(contractId, documentType, req); + // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer + await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate); res.status(201).json({ success: true, data: doc } as ApiResponse); } catch (error) { diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index 2914eeb2..4a26a5e0 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -563,10 +563,30 @@ async function handleContractDocumentUpload( } } + // Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart + // übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen. + const updateData: Record = { [fieldName]: relativePath }; + if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') { + const dateField = fieldName === 'cancellationConfirmationPath' + ? 'cancellationConfirmationDate' + : 'cancellationConfirmationOptionsDate'; + const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null; + let target: Date | null = null; + if (provided) { + const parsed = new Date(provided); + if (!isNaN(parsed.getTime())) target = parsed; + } + if (target) { + updateData[dateField] = target; + } else if (!contract[dateField]) { + updateData[dateField] = new Date(); + } + } + // Vertrag in der DB aktualisieren await prisma.contract.update({ where: { id: contractId }, - data: { [fieldName]: relativePath }, + data: updateData, }); // Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und diff --git a/backend/src/services/contractStatusScheduler.service.ts b/backend/src/services/contractStatusScheduler.service.ts index 60b6a288..762dab4e 100644 --- a/backend/src/services/contractStatusScheduler.service.ts +++ b/backend/src/services/contractStatusScheduler.service.ts @@ -86,8 +86,9 @@ export { runExpireCheck }; /** * Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine - * Lieferbestätigung ist UND der Vertrag aktuell DRAFT ist, wird er auf - * ACTIVE gesetzt (+ Audit-Log). Andere Typen/Status bleiben unangetastet. + * Lieferbestätigung ist: + * - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT) + * - Contract.startDate auf deliveryDate (oder heute) setzen, falls noch leer * * Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown * (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive + @@ -97,19 +98,43 @@ export async function maybeActivateOnDeliveryConfirmation( contractId: number, documentType: string, req: unknown, + deliveryDate?: Date | string | null, ): Promise { if (!documentType || typeof documentType !== 'string') return; if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return; const contract = await prisma.contract.findUnique({ where: { id: contractId }, - select: { status: true, contractNumber: true, customerId: true }, + select: { status: true, contractNumber: true, customerId: true, startDate: true }, }); - if (!contract || contract.status !== 'DRAFT') return; + if (!contract) return; + + // deliveryDate parsen, Fallback auf heute + let parsedDate: Date | null = null; + if (deliveryDate) { + const parsed = new Date(deliveryDate); + if (!isNaN(parsed.getTime())) parsedDate = parsed; + } + const effectiveDate = parsedDate || new Date(); + + const updateData: Record = {}; + const changes: Record = {}; + + if (contract.status === 'DRAFT') { + updateData.status = 'ACTIVE'; + changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' }; + } + + if (!contract.startDate) { + updateData.startDate = effectiveDate; + changes.startDate = { vorher: null, nachher: effectiveDate.toISOString().split('T')[0] }; + } + + if (Object.keys(updateData).length === 0) return; await prisma.contract.update({ where: { id: contractId }, - data: { status: 'ACTIVE' }, + data: updateData, }); await logChange({ @@ -117,8 +142,8 @@ export async function maybeActivateOnDeliveryConfirmation( action: 'UPDATE', resourceType: 'Contract', resourceId: contractId.toString(), - label: `Vertrag ${contract.contractNumber} automatisch auf ACTIVE gesetzt (Lieferbestätigung hochgeladen)`, - details: { vorher: 'DRAFT', nachher: 'ACTIVE', trigger: 'Lieferbestätigung-Upload' }, + label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`, + details: { ...changes, trigger: 'Lieferbestätigung-Upload' }, customerId: contract.customerId, }); } diff --git a/backend/todo.md b/backend/todo.md index b1bfb362..03afa548 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -102,12 +102,16 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung `status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log). - Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`): wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log). - Der "Optionen"-Upload löst den Wechsel bewusst NICHT aus, da er für - Vertragsänderungen (nicht echte Kündigungen) gedacht ist. + Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute), + wird direkt als `cancellationConfirmationDate` gespeichert. + Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er + für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt + aber `cancellationConfirmationOptionsDate` analog. - Beim Upload einer `Lieferbestätigung` (ContractDocument via direkt-Upload oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` → auf `ACTIVE` - setzen (Audit-Log). Schreibweise stammt aus dem Frontend-Dropdown, - Vergleich case-insensitive + getrimmt. + setzen + `startDate` auf das erfasste Lieferdatum (falls leer). + Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung" + ausgewählt ist. - Keine neuen Status eingeführt: `cancellationSentDate` vs. `cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt" abzubilden. `ACTIVE` bleibt bis zur Bestätigung. diff --git a/frontend/src/components/email/SaveAttachmentModal.tsx b/frontend/src/components/email/SaveAttachmentModal.tsx index ee1c8760..60866556 100644 --- a/frontend/src/components/email/SaveAttachmentModal.tsx +++ b/frontend/src/components/email/SaveAttachmentModal.tsx @@ -56,6 +56,7 @@ export default function SaveAttachmentModal({ const [contractDocumentData, setContractDocumentData] = useState({ documentType: CONTRACT_DOCUMENT_TYPES[0], notes: '', + deliveryDate: new Date().toISOString().split('T')[0], }); const queryClient = useQueryClient(); @@ -130,9 +131,11 @@ export default function SaveAttachmentModal({ const saveContractDocumentMutation = useMutation({ mutationFn: () => { + const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung'; return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, { documentType: contractDocumentData.documentType, notes: contractDocumentData.notes || undefined, + deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined, }); }, onSuccess: () => { @@ -164,6 +167,7 @@ export default function SaveAttachmentModal({ setContractDocumentData({ documentType: CONTRACT_DOCUMENT_TYPES[0], notes: '', + deliveryDate: new Date().toISOString().split('T')[0], }); onClose(); }; @@ -459,6 +463,23 @@ export default function SaveAttachmentModal({ } placeholder="Optionale Anmerkungen..." /> + + {contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung' && ( +
+ + + setContractDocumentData({ ...contractDocumentData, deliveryDate: e.target.value }) + } + className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +

+ Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen. +

+
+ )} )} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index cb1e9e6e..5dc9054f 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -1471,6 +1471,12 @@ export default function ContractDetail() { // Un-Snooze Bestätigungsmodal const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false); + // Kündigungsbestätigung-Upload: File gepuffert, Datum-Modal offen + const [pendingCancelFile, setPendingCancelFile] = useState(null); + const [cancelConfirmDate, setCancelConfirmDate] = useState( + () => new Date().toISOString().split('T')[0] + ); + const { data, isLoading } = useQuery({ queryKey: ['contract', id], queryFn: () => contractApi.getById(contractId), @@ -2103,8 +2109,13 @@ export default function ContractDetail() { { - await uploadApi.uploadCancellationConfirmation(contractId, file); - queryClient.invalidateQueries({ queryKey: ['contract', id] }); + // Datei puffern, Datums-Modal öffnen + setCancelConfirmDate( + c.cancellationConfirmationDate + ? c.cancellationConfirmationDate.split('T')[0] + : new Date().toISOString().split('T')[0] + ); + setPendingCancelFile(file); }} existingFile={c.cancellationConfirmationPath} accept=".pdf" @@ -2151,8 +2162,8 @@ export default function ContractDetail() { ) : ( { - await uploadApi.uploadCancellationConfirmation(contractId, file); - queryClient.invalidateQueries({ queryKey: ['contract', id] }); + setCancelConfirmDate(new Date().toISOString().split('T')[0]); + setPendingCancelFile(file); }} accept=".pdf" label="PDF hochladen" @@ -3068,6 +3079,52 @@ export default function ContractDetail() { {/* Status-Info Modal */} setShowStatusInfo(false)} /> + {/* Kündigungsbestätigung: Datum erfassen und dann Upload */} + setPendingCancelFile(null)} + title="Kündigungsbestätigung – Datum angeben" + size="sm" + > +
+

+ Wann wurde die Kündigung vom Anbieter bestätigt? Du kannst das Datum auch später noch anpassen. +

+
+ + setCancelConfirmDate(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ {/* Un-Snooze Bestätigungsmodal */} ( + () => new Date().toISOString().split('T')[0], + ); const { data: docsData } = useQuery({ queryKey: ['contract-documents', contractId], @@ -3130,10 +3190,12 @@ function ContractDocumentsSection({ }); const uploadMutation = useMutation({ - mutationFn: ({ file, documentType, notes }: { file: File; documentType: string; notes?: string }) => - contractApi.uploadDocument(contractId, file, documentType, notes), + mutationFn: ({ file, documentType, notes, deliveryDate }: { file: File; documentType: string; notes?: string; deliveryDate?: string }) => + contractApi.uploadDocument(contractId, file, documentType, notes, deliveryDate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] }); + // Contract selbst neu laden – Status kann sich durch Lieferbestätigung geändert haben + queryClient.invalidateQueries({ queryKey: ['contract'] }); setShowUpload(false); setUploadNotes(''); }, @@ -3148,10 +3210,17 @@ function ContractDocumentsSection({ const documents: ContractDocument[] = docsData?.data || []; + const isDelivery = uploadType.trim().toLowerCase() === 'lieferbestätigung'; + const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - uploadMutation.mutate({ file, documentType: uploadType, notes: uploadNotes || undefined }); + uploadMutation.mutate({ + file, + documentType: uploadType, + notes: uploadNotes || undefined, + deliveryDate: isDelivery ? uploadDeliveryDate : undefined, + }); } }; @@ -3197,6 +3266,20 @@ function ContractDocumentsSection({ /> + {isDelivery && ( +
+ + setUploadDeliveryDate(e.target.value)} + className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +

+ Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen. +

+
+ )}