INSIGHT-MVP/packages/frontend/src/crm/companies/ContractsCard.tsx
Thomas Reitz bfe672ec96 feat(crm): Vertragsdokumente — Datei-Upload Frontend + Backend-Briefing
Frontend (gegen API-Contract, bereit fuer Backend-Integration):
- ContractFile Interface in types.ts
- contractFilesApi (upload, list, download-as-blob, delete) in api.ts
- useContractFiles, useUploadContractFile, useDeleteContractFile in hooks.ts
- ContractsCard: Dokumente-Sektion im Edit-Modal
  - Dateiliste mit Icon (PDF/Word/Excel), Name, Groesse, Download, Loeschen
  - Upload-Button (PDF/DOC/DOCX/XLSX/XLS, max 25 MB)
  - Client-seitige Groessenvalidierung
  - Blob-Download via Axios (Auth-Header werden mitgesendet)

Backend-Briefing in INSIGHT-CRM.md:
- contract_files Tabelle + Prisma-Modell
- Datei-Speicherung (/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{name})
- 4 Endpoints (POST/GET/GET-download/DELETE)
- Sicherheitshinweise (Path Traversal, Tenant-Isolation, DSGVO)
- Docker-Volume Konfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:46:32 +01:00

749 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef, useState } from 'react';
import { Modal } from '../../components/Modal';
import {
useContracts,
useCreateContract,
useUpdateContract,
useDeleteContract,
useContractFiles,
useUploadContractFile,
useDeleteContractFile,
} from '../hooks';
import { contractFilesApi } from '../api';
import type {
Contract,
ContractFile,
ContractStatus,
CreateContractPayload,
} from '../types';
import styles from './CompanyDetailPage.module.css';
// ============================================================
// Constants
// ============================================================
const STATUS_LABELS: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
ACTIVE: 'Aktiv',
EXPIRED: 'Abgelaufen',
CANCELLED: 'Storniert',
};
const STATUS_COLORS: Record<ContractStatus, { bg: string; color: string }> = {
DRAFT: { bg: '#f3f4f6', color: '#6b7280' },
ACTIVE: { bg: '#dcfce7', color: '#16a34a' },
EXPIRED: { bg: '#fef3c7', color: '#d97706' },
CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
};
const ALLOWED_TYPES = '.pdf,.doc,.docx,.xlsx,.xls';
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
// ============================================================
// Helpers
// ============================================================
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatValue(val: string | null, currency = 'EUR'): string {
if (!val) return '—';
const num = parseFloat(val);
if (isNaN(num)) return val;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileIcon(mimeType: string): string {
if (mimeType === 'application/pdf') return '📄';
if (mimeType.includes('word')) return '📝';
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊';
return '📎';
}
// ============================================================
// Props
// ============================================================
interface ContractsCardProps {
companyId: string;
}
// ============================================================
// Component
// ============================================================
export function ContractsCard({ companyId }: ContractsCardProps) {
const { data, isLoading } = useContracts(companyId);
const deleteMut = useDeleteContract(companyId);
const [isAddOpen, setAddOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
const contracts: Contract[] = data?.data ?? [];
const handleDelete = (contract: Contract) => {
if (
window.confirm(
`Vertrag "${contract.title}" wirklich löschen? Alle angehängten Dateien werden ebenfalls gelöscht.`,
)
) {
deleteMut.mutate(contract.id);
}
};
return (
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Verträge ({contracts.length})
</h2>
<button className={styles.smallAddBtn} onClick={() => setAddOpen(true)}>
+ Neu
</button>
</div>
{isLoading ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Laden...
</p>
) : contracts.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Verträge vorhanden
</p>
) : (
<div>
{contracts.map((c) => (
<div key={c.id} className={styles.relationItem}>
<div className={styles.relationInfo}>
<span
className={styles.relationName}
style={{ cursor: 'default' }}
title={c.title}
>
{c.title}
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '0.125rem 0.375rem',
borderRadius: '999px',
background: STATUS_COLORS[c.status].bg,
color: STATUS_COLORS[c.status].color,
}}
>
{STATUS_LABELS[c.status]}
</span>
{c.value && (
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text)',
fontWeight: 500,
}}
>
{formatValue(c.value, c.currency)}
</span>
)}
<span
style={{
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
}}
>
{formatDate(c.startDate)}
{c.endDate ? ` ${formatDate(c.endDate)}` : ''}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
className={styles.relationDeleteBtn}
onClick={() => setEditContract(c)}
title="Bearbeiten / Dokumente"
style={{ color: 'var(--color-text-muted)' }}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8.5 1.5l2 2L4 10H2v-2L8.5 1.5z" />
</svg>
</button>
<button
className={styles.relationDeleteBtn}
onClick={() => handleDelete(c)}
title="Löschen"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
<ContractFormModal
isOpen={isAddOpen}
onClose={() => setAddOpen(false)}
companyId={companyId}
mode="create"
/>
{/* Edit Modal */}
{editContract && (
<ContractFormModal
isOpen={true}
onClose={() => setEditContract(null)}
companyId={companyId}
mode="edit"
contract={editContract}
/>
)}
</div>
);
}
// ============================================================
// Contract Form Modal (Create + Edit)
// ============================================================
interface ContractFormModalProps {
isOpen: boolean;
onClose: () => void;
companyId: string;
mode: 'create' | 'edit';
contract?: Contract;
}
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
};
function ContractFormModal({
isOpen,
onClose,
companyId,
mode,
contract,
}: ContractFormModalProps) {
const createMut = useCreateContract(companyId);
const updateMut = useUpdateContract(companyId);
const [title, setTitle] = useState(contract?.title ?? '');
const [status, setStatus] = useState<ContractStatus>(contract?.status ?? 'DRAFT');
const [startDate, setStartDate] = useState(
contract?.startDate ? contract.startDate.substring(0, 10) : '',
);
const [endDate, setEndDate] = useState(
contract?.endDate ? contract.endDate.substring(0, 10) : '',
);
const [value, setValue] = useState(contract?.value ?? '');
const [currency, setCurrency] = useState(contract?.currency ?? 'EUR');
const [notes, setNotes] = useState(contract?.notes ?? '');
const [error, setError] = useState('');
const isPending = createMut.isPending || updateMut.isPending;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!title.trim()) {
setError('Titel ist ein Pflichtfeld.');
return;
}
const payload: CreateContractPayload = {
title: title.trim(),
status,
startDate: startDate || null,
endDate: endDate || null,
value: value.trim() || null,
currency: currency || 'EUR',
notes: notes.trim() || null,
};
if (mode === 'create') {
createMut.mutate(payload, {
onSuccess: () => onClose(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Erstellen';
setError(msg);
},
});
} else if (contract) {
updateMut.mutate(
{ contractId: contract.id, data: payload },
{
onSuccess: () => onClose(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
maxWidth="560px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
{/* Titel */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Titel *</label>
<input
style={inputStyle}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Wartungsvertrag 2026"
required
autoFocus
/>
</div>
{/* Status */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Status</label>
<select
style={inputStyle}
value={status}
onChange={(e) => setStatus(e.target.value as ContractStatus)}
>
{(Object.keys(STATUS_LABELS) as ContractStatus[]).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</div>
{/* Laufzeit */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
marginBottom: '1rem',
}}
>
<div>
<label style={labelStyle}>Beginn</label>
<input
style={inputStyle}
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>Ende</label>
<input
style={inputStyle}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{/* Wert + Währung */}
<div
style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr',
gap: '0.75rem',
marginBottom: '1rem',
}}
>
<div>
<label style={labelStyle}>Vertragswert</label>
<input
style={inputStyle}
type="number"
min="0"
step="0.01"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.00"
/>
</div>
<div>
<label style={labelStyle}>Währung</label>
<select
style={inputStyle}
value={currency}
onChange={(e) => setCurrency(e.target.value)}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CHF">CHF</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
{/* Notizen */}
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{
...inputStyle,
cursor: 'text',
minHeight: 64,
resize: 'vertical',
}}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Notizen..."
/>
</div>
{/* Dokumente — nur im Edit-Modus */}
{mode === 'edit' && contract && (
<FilesSection companyId={companyId} contractId={contract.id} />
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button
type="button"
onClick={onClose}
disabled={isPending}
style={{
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: isPending ? 'wait' : 'pointer',
opacity: isPending ? 0.7 : 1,
}}
>
{isPending
? mode === 'create'
? 'Erstellen...'
: 'Speichern...'
: mode === 'create'
? 'Erstellen'
: 'Speichern'}
</button>
</div>
</form>
</Modal>
);
}
// ============================================================
// Files Section (inside Edit Modal)
// ============================================================
interface FilesSectionProps {
companyId: string;
contractId: string;
}
function FilesSection({ companyId, contractId }: FilesSectionProps) {
const { data, isLoading } = useContractFiles(companyId, contractId);
const uploadMut = useUploadContractFile(companyId, contractId);
const deleteMut = useDeleteContractFile(companyId, contractId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadError, setUploadError] = useState('');
const files: ContractFile[] = data?.data ?? [];
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadError('');
if (file.size > MAX_FILE_SIZE) {
setUploadError(`Datei zu groß (max. 25 MB). Gewählt: ${formatFileSize(file.size)}`);
e.target.value = '';
return;
}
uploadMut.mutate(file, {
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Upload fehlgeschlagen';
setUploadError(msg);
},
});
e.target.value = '';
};
const handleDownload = async (file: ContractFile) => {
try {
const blob = await contractFilesApi.download(companyId, contractId, file.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.originalName;
a.click();
URL.revokeObjectURL(url);
} catch {
// silently ignore
}
};
const handleDelete = (file: ContractFile) => {
if (window.confirm(`Datei "${file.originalName}" wirklich löschen?`)) {
deleteMut.mutate(file.id);
}
};
return (
<div
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '1rem',
marginBottom: '1.25rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.625rem',
}}
>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text)' }}>
Dokumente
</span>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadMut.isPending}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.625rem',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
cursor: uploadMut.isPending ? 'wait' : 'pointer',
color: 'var(--color-text)',
opacity: uploadMut.isPending ? 0.6 : 1,
}}
>
{uploadMut.isPending ? (
'Hochladen...'
) : (
<>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 1v7M3 4l3-3 3 3M1 10h10" />
</svg>
Hochladen
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_TYPES}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{uploadError && (
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '0.5rem',
}}
>
{uploadError}
</p>
)}
{isLoading ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>Laden...</p>
) : files.length === 0 ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
Noch keine Dokumente PDF, Word oder Excel hochladen (max. 25 MB)
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{files.map((f) => (
<div
key={f.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.625rem',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
}}
>
<span style={{ fontSize: '1rem', flexShrink: 0 }}>{fileIcon(f.mimeType)}</span>
<span
style={{
flex: 1,
fontSize: '0.8125rem',
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={f.originalName}
>
{f.originalName}
</span>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
{formatFileSize(f.size)}
</span>
<button
type="button"
onClick={() => handleDownload(f)}
title="Herunterladen"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
color: 'var(--color-primary)',
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 1v7M3 8l3 3 3-3M1 11h10" />
</svg>
</button>
<button
type="button"
onClick={() => handleDelete(f)}
title="Löschen"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
);
}