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
|
||||
|
||||
`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,
|
||||
EnrichmentConfig,
|
||||
Contract,
|
||||
ContractFile,
|
||||
CreateContractPayload,
|
||||
UpdateContractPayload,
|
||||
ContractsQueryParams,
|
||||
|
|
@ -707,3 +708,44 @@ export const contractsApi = {
|
|||
)
|
||||
.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 {
|
||||
useContracts,
|
||||
useCreateContract,
|
||||
useUpdateContract,
|
||||
useDeleteContract,
|
||||
useContractFiles,
|
||||
useUploadContractFile,
|
||||
useDeleteContractFile,
|
||||
} 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';
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -27,6 +36,9 @@ const STATUS_COLORS: Record<ContractStatus, { bg: string; color: string }> = {
|
|||
CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = '.pdf,.doc,.docx,.xlsx,.xls';
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
|
@ -52,6 +64,19 @@ function formatValue(val: string | null, currency = 'EUR'): string {
|
|||
}).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
|
||||
// ============================================================
|
||||
|
|
@ -76,7 +101,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
|
|||
const handleDelete = (contract: Contract) => {
|
||||
if (
|
||||
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);
|
||||
|
|
@ -160,7 +185,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
|
|||
<button
|
||||
className={styles.relationDeleteBtn}
|
||||
onClick={() => setEditContract(c)}
|
||||
title="Bearbeiten"
|
||||
title="Bearbeiten / Dokumente"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -328,7 +353,7 @@ function ContractFormModal({
|
|||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
|
||||
maxWidth="520px"
|
||||
maxWidth="560px"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
|
|
@ -442,13 +467,13 @@ function ContractFormModal({
|
|||
</div>
|
||||
|
||||
{/* Notizen */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<label style={labelStyle}>Notizen</label>
|
||||
<textarea
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: 'text',
|
||||
minHeight: 72,
|
||||
minHeight: 64,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
value={notes}
|
||||
|
|
@ -457,6 +482,11 @@ function ContractFormModal({
|
|||
/>
|
||||
</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"
|
||||
|
|
@ -502,3 +532,218 @@ function ContractFormModal({
|
|||
</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,
|
||||
enrichmentApi,
|
||||
contractsApi,
|
||||
contractFilesApi,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -156,6 +157,11 @@ export const crmKeys = {
|
|||
list: (companyId: string, params?: ContractsQueryParams) =>
|
||||
['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;
|
||||
}
|
||||
|
||||
export interface ContractFile {
|
||||
id: string;
|
||||
contractId: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateContractPayload {
|
||||
title: string;
|
||||
status?: ContractStatus;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue