mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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>
749 lines
22 KiB
TypeScript
749 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|