mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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>
This commit is contained in:
parent
0e6565e210
commit
bfe672ec96
5 changed files with 527 additions and 7 deletions
|
|
@ -3257,3 +3257,183 @@ Die `ContractsCard.tsx` ist vollstaendig implementiert. Das "Modul in Entwicklun
|
||||||
### TypeScript-Check
|
### TypeScript-Check
|
||||||
|
|
||||||
`npx tsc --noEmit` — 0 Fehler
|
`npx tsc --noEmit` — 0 Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 | Plattform-Admin: Briefing — Vertragsdokumente (Datei-Upload)
|
||||||
|
|
||||||
|
### Anforderung
|
||||||
|
|
||||||
|
An jedem Vertrag soll eine oder mehrere Dateien (primär PDFs) hochgeladen werden koennen — z.B. das unterschriebene Original-Dokument, Anlagen oder Nachtraege.
|
||||||
|
|
||||||
|
### Neue DB-Tabelle: `contract_files`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_crm.contract_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
contract_id UUID NOT NULL REFERENCES app_crm.contracts(id) ON DELETE CASCADE,
|
||||||
|
original_name VARCHAR(500) NOT NULL,
|
||||||
|
storage_path VARCHAR(1000) NOT NULL, -- relativer Pfad auf Disk
|
||||||
|
mime_type VARCHAR(200) NOT NULL,
|
||||||
|
size INTEGER NOT NULL, -- Bytes
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX ON app_crm.contract_files(contract_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prisma-Modell (in `crm.schema.prisma` erganzen):**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ContractFile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
contractId String @map("contract_id") @db.Uuid
|
||||||
|
originalName String @map("original_name") @db.VarChar(500)
|
||||||
|
storagePath String @map("storage_path") @db.VarChar(1000)
|
||||||
|
mimeType String @map("mime_type") @db.VarChar(200)
|
||||||
|
size Int
|
||||||
|
uploadedBy String @map("uploaded_by") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("contract_files")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rueckwaertsrelation im Contract-Modell erganzen:**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Contract {
|
||||||
|
// ... bestehende Felder ...
|
||||||
|
files ContractFile[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datei-Speicherung
|
||||||
|
|
||||||
|
- Ablageort: `/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{originalName}`
|
||||||
|
- Docker-Volume: `./uploads:/app/uploads` (im docker-compose.crm.yml erganzen)
|
||||||
|
- Verzeichnis wird automatisch beim ersten Upload erstellt (`fs.mkdirSync(..., { recursive: true })`)
|
||||||
|
- Dateiname auf Disk: `{uuid}-{originalName}` (UUID-Prefix verhindert Kollisionen und path-traversal)
|
||||||
|
|
||||||
|
### Erlaubte Dateitypen und Groessen
|
||||||
|
|
||||||
|
| Typ | MIME | Max |
|
||||||
|
|-----|------|-----|
|
||||||
|
| PDF | application/pdf | 25 MB |
|
||||||
|
| Word | application/msword, .../wordprocessingml.document | 25 MB |
|
||||||
|
| Excel | application/vnd.ms-excel, .../spreadsheetml.sheet | 25 MB |
|
||||||
|
|
||||||
|
- Multer-Validierung: `fileFilter` + `limits: { fileSize: 26_214_400 }` (25 MB)
|
||||||
|
- Max. Dateien pro Vertrag: 10
|
||||||
|
|
||||||
|
### Neue Endpoints (4)
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `POST` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files` | Datei hochladen (multipart/form-data, field: `file`) |
|
||||||
|
| `GET` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files` | Datei-Liste abrufen |
|
||||||
|
| `GET` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files/:fileId/download` | Datei herunterladen / anzeigen |
|
||||||
|
| `DELETE` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files/:fileId` | Datei loeschen |
|
||||||
|
|
||||||
|
#### POST /files — Upload
|
||||||
|
|
||||||
|
- Multer `FileInterceptor('file')`
|
||||||
|
- Validierung: Dateityp + Groesse
|
||||||
|
- Speichern auf Disk
|
||||||
|
- ContractFile-Record in DB anlegen
|
||||||
|
- **Sicherheit:** tenantId aus JWT, nicht aus Request-Body!
|
||||||
|
- Tenant-Pruefung: Contract muss zum Tenant gehoeren
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "uuid",
|
||||||
|
"contractId": "uuid",
|
||||||
|
"originalName": "Wartungsvertrag_2026.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": 245678,
|
||||||
|
"uploadedBy": "uuid",
|
||||||
|
"createdAt": "2026-03-12T..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /files — Liste
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"contractId": "uuid",
|
||||||
|
"originalName": "Wartungsvertrag_2026.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": 245678,
|
||||||
|
"uploadedBy": "uuid",
|
||||||
|
"createdAt": "2026-03-12T..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /files/:fileId/download — Herunterladen / Inline anzeigen
|
||||||
|
|
||||||
|
- Query-Parameter: `?inline=true` (optional) — setzt `Content-Disposition: inline` statt `attachment`
|
||||||
|
- Default: `Content-Disposition: attachment; filename="Wartungsvertrag_2026.pdf"`
|
||||||
|
- Content-Type: aus DB (`mimeType`)
|
||||||
|
- Implementierung: `@Res() res: Response`, `res.sendFile(absolutePath)` oder NestJS `StreamableFile`
|
||||||
|
- **Sicherheit:** Pruefung dass fileId zum tenantId gehoert, KEIN direktes Auslesen von `storagePath` aus Request
|
||||||
|
|
||||||
|
#### DELETE /files/:fileId — Loeschen
|
||||||
|
|
||||||
|
- DB-Record loeschen
|
||||||
|
- Datei auf Disk loeschen (`fs.unlink`)
|
||||||
|
- Falls Datei auf Disk nicht gefunden: trotzdem DB-Record loeschen (kein Fehler)
|
||||||
|
|
||||||
|
### Sicherheits-Hinweise
|
||||||
|
|
||||||
|
1. **Path Traversal**: `originalName` NIEMALS direkt als Pfad verwenden. Immer UUID-Prefix auf Disk.
|
||||||
|
2. **Tenant-Isolation**: Jeder File-Zugriff muss `tenantId` aus JWT gegen `contract.tenantId` pruefen.
|
||||||
|
3. **Upload-Limit**: Max 10 Dateien pro Vertrag — pruefe Anzahl vor Upload.
|
||||||
|
4. **DSGVO**: Dateien werden beim Loeschen des Vertrags automatisch entfernt (Cascade in DB + Cron-Job oder onDelete-Hook fuer Disk-Cleanup).
|
||||||
|
|
||||||
|
### Modulstruktur
|
||||||
|
|
||||||
|
- Kein eigenes Modul noetig — in `ContractsModule` (`src/contracts/`) integrieren:
|
||||||
|
- `contracts.controller.ts` — neue Routen erganzen
|
||||||
|
- `contracts.service.ts` — `uploadFile`, `listFiles`, `downloadFile`, `deleteFile` Methoden
|
||||||
|
- `multer` bereits als `@nestjs/platform-express` Peer-Dependency vorhanden
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
```
|
||||||
|
prisma/migrations/20260312_contract_files/migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach Migration: `docker compose ... up -d crm --force-recreate -V`
|
||||||
|
|
||||||
|
Und im docker-compose.crm.yml:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
### TODO fuer Frontend
|
||||||
|
|
||||||
|
Nach Implementierung der 4 Endpoints:
|
||||||
|
|
||||||
|
- [ ] `ContractFile`-Interface in `types.ts`
|
||||||
|
- [ ] `contractFilesApi` in `api.ts` (upload, list, download als Blob, delete)
|
||||||
|
- [ ] `useContractFiles`, `useUploadContractFile`, `useDeleteContractFile` in `hooks.ts`
|
||||||
|
- [ ] `ContractsCard.tsx` — Dateien-Sektion im Edit-Modal: Upload-Button, Dateiliste mit Download + Loeschen
|
||||||
|
|
||||||
|
**Hinweis:** Das Frontend kann vorab gegen den definierten API-Contract gebaut werden. Sobald die Backend-Endpoints live sind, ist die Integration sofort funktional.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import type {
|
||||||
EnrichmentResponse,
|
EnrichmentResponse,
|
||||||
EnrichmentConfig,
|
EnrichmentConfig,
|
||||||
Contract,
|
Contract,
|
||||||
|
ContractFile,
|
||||||
CreateContractPayload,
|
CreateContractPayload,
|
||||||
UpdateContractPayload,
|
UpdateContractPayload,
|
||||||
ContractsQueryParams,
|
ContractsQueryParams,
|
||||||
|
|
@ -707,3 +708,44 @@ export const contractsApi = {
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Contract Files ---
|
||||||
|
|
||||||
|
export const contractFilesApi = {
|
||||||
|
list: (companyId: string, contractId: string) =>
|
||||||
|
api
|
||||||
|
.get<{ success: boolean; data: ContractFile[]; meta: { timestamp: string } }>(
|
||||||
|
`/crm/companies/${companyId}/contracts/${contractId}/files`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
upload: (companyId: string, contractId: string, file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return api
|
||||||
|
.post<SingleResponse<ContractFile>>(
|
||||||
|
`/crm/companies/${companyId}/contracts/${contractId}/files`,
|
||||||
|
form,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
download: (companyId: string, contractId: string, fileId: string, inline = false) =>
|
||||||
|
api
|
||||||
|
.get(
|
||||||
|
`/crm/companies/${companyId}/contracts/${contractId}/files/${fileId}/download`,
|
||||||
|
{
|
||||||
|
responseType: 'blob',
|
||||||
|
params: inline ? { inline: 'true' } : undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((r) => r.data as Blob),
|
||||||
|
|
||||||
|
delete: (companyId: string, contractId: string, fileId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<ContractFile>>(
|
||||||
|
`/crm/companies/${companyId}/contracts/${contractId}/files/${fileId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import {
|
import {
|
||||||
useContracts,
|
useContracts,
|
||||||
useCreateContract,
|
useCreateContract,
|
||||||
useUpdateContract,
|
useUpdateContract,
|
||||||
useDeleteContract,
|
useDeleteContract,
|
||||||
|
useContractFiles,
|
||||||
|
useUploadContractFile,
|
||||||
|
useDeleteContractFile,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import type { Contract, ContractStatus, CreateContractPayload } from '../types';
|
import { contractFilesApi } from '../api';
|
||||||
|
import type {
|
||||||
|
Contract,
|
||||||
|
ContractFile,
|
||||||
|
ContractStatus,
|
||||||
|
CreateContractPayload,
|
||||||
|
} from '../types';
|
||||||
import styles from './CompanyDetailPage.module.css';
|
import styles from './CompanyDetailPage.module.css';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -27,6 +36,9 @@ const STATUS_COLORS: Record<ContractStatus, { bg: string; color: string }> = {
|
||||||
CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
|
CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = '.pdf,.doc,.docx,.xlsx,.xls';
|
||||||
|
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -52,6 +64,19 @@ function formatValue(val: string | null, currency = 'EUR'): string {
|
||||||
}).format(num);
|
}).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
|
// Props
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -76,7 +101,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
|
||||||
const handleDelete = (contract: Contract) => {
|
const handleDelete = (contract: Contract) => {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
`Vertrag "${contract.title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
|
`Vertrag "${contract.title}" wirklich löschen? Alle angehängten Dateien werden ebenfalls gelöscht.`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
deleteMut.mutate(contract.id);
|
deleteMut.mutate(contract.id);
|
||||||
|
|
@ -160,7 +185,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
|
||||||
<button
|
<button
|
||||||
className={styles.relationDeleteBtn}
|
className={styles.relationDeleteBtn}
|
||||||
onClick={() => setEditContract(c)}
|
onClick={() => setEditContract(c)}
|
||||||
title="Bearbeiten"
|
title="Bearbeiten / Dokumente"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -328,7 +353,7 @@ function ContractFormModal({
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
|
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
|
||||||
maxWidth="520px"
|
maxWidth="560px"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -442,13 +467,13 @@ function ContractFormModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notizen */}
|
{/* Notizen */}
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<label style={labelStyle}>Notizen</label>
|
<label style={labelStyle}>Notizen</label>
|
||||||
<textarea
|
<textarea
|
||||||
style={{
|
style={{
|
||||||
...inputStyle,
|
...inputStyle,
|
||||||
cursor: 'text',
|
cursor: 'text',
|
||||||
minHeight: 72,
|
minHeight: 64,
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
}}
|
}}
|
||||||
value={notes}
|
value={notes}
|
||||||
|
|
@ -457,6 +482,11 @@ function ContractFormModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -502,3 +532,218 @@ function ContractFormModal({
|
||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
importApi,
|
importApi,
|
||||||
enrichmentApi,
|
enrichmentApi,
|
||||||
contractsApi,
|
contractsApi,
|
||||||
|
contractFilesApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
|
|
@ -156,6 +157,11 @@ export const crmKeys = {
|
||||||
list: (companyId: string, params?: ContractsQueryParams) =>
|
list: (companyId: string, params?: ContractsQueryParams) =>
|
||||||
['crm', 'contracts', 'list', companyId, params] as const,
|
['crm', 'contracts', 'list', companyId, params] as const,
|
||||||
},
|
},
|
||||||
|
contractFiles: {
|
||||||
|
all: ['crm', 'contractFiles'] as const,
|
||||||
|
list: (companyId: string, contractId: string) =>
|
||||||
|
['crm', 'contractFiles', 'list', companyId, contractId] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -1226,3 +1232,40 @@ export function useDeleteContract(companyId: string) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Contract Files
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useContractFiles(companyId: string, contractId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.contractFiles.list(companyId, contractId),
|
||||||
|
queryFn: () => contractFilesApi.list(companyId, contractId),
|
||||||
|
enabled: !!companyId && !!contractId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadContractFile(companyId: string, contractId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => contractFilesApi.upload(companyId, contractId, file),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: crmKeys.contractFiles.list(companyId, contractId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteContractFile(companyId: string, contractId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (fileId: string) =>
|
||||||
|
contractFilesApi.delete(companyId, contractId, fileId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: crmKeys.contractFiles.list(companyId, contractId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,16 @@ export interface Contract {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractFile {
|
||||||
|
id: string;
|
||||||
|
contractId: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateContractPayload {
|
export interface CreateContractPayload {
|
||||||
title: string;
|
title: string;
|
||||||
status?: ContractStatus;
|
status?: ContractStatus;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue