T08a: Frontend DMS + Tags + Permissions UI — 33 tests, tsc clean, vite build pass

- DMS file browser: folder tree + file grid + upload dropzone + search + preview modal
- DMS share dialog: user/group share + public share links with password+expiry
- DMS bulk actions: bulk move + bulk delete with confirm dialogs
- DMS trash view: deleted files list with restore button
- Tags: TagPicker on company/contact detail pages (new tabs tab)
- Tags: TagCloud + BulkTagDialog for bulk tag assignment
- Permissions: share link creation, permission display, copy-link button
- API clients: dms.ts, tags.ts, permissions.ts
- Routes: /dms, /dms/trash added to router
- Sidebar: DMS nav link updated
- i18n: de.json + en.json translations for DMS/Tags/Permissions
- 33 new tests (5 test files), full regression 276/276 pass
- tsc --noEmit: 0 errors, vite build: 252 modules
This commit is contained in:
leocrm-bot
2026-07-01 16:54:32 +02:00
parent f646c597dc
commit 0962f3a961
31 changed files with 3368 additions and 41 deletions
@@ -0,0 +1,165 @@
/**
* Upload dropzone for DMS file browser.
* Supports drag-and-drop and click-to-select file uploads with progress.
*/
import React, { useCallback, useRef, useState } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { uploadFile } from '@/api/dms';
import { useToast } from '@/components/ui/Toast';
export interface UploadDropzoneProps {
folderId: string | null;
onUploaded: () => void;
}
interface UploadProgress {
fileName: string;
progress: number;
error: string | null;
}
export function UploadDropzone({ folderId, onUploaded }: UploadDropzoneProps) {
const { t } = useTranslation();
const toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [uploads, setUploads] = useState<UploadProgress[]>([]);
const [isUploading, setIsUploading] = useState(false);
const handleFiles = useCallback(async (fileList: FileList) => {
const files = Array.from(fileList);
if (files.length === 0) return;
setIsUploading(true);
const progressEntries = files.map((f) => ({ fileName: f.name, progress: 0, error: null as string | null }));
setUploads(progressEntries);
let allSuccess = true;
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, progress: 50 } : u));
await uploadFile(file, folderId);
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, progress: 100 } : u));
} catch (err) {
allSuccess = false;
const errorMsg = err instanceof Error ? err.message : String(err);
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, error: errorMsg } : u));
}
}
setIsUploading(false);
if (allSuccess) {
toast.success(t('dms.uploadSuccess'));
} else {
toast.error(t('dms.uploadError'));
}
onUploaded();
setTimeout(() => setUploads([]), 3000);
}, [folderId, onUploaded, toast, t]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
}, [handleFiles]);
const handleClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files);
e.target.value = '';
}
}, [handleFiles]);
return (
<div data-testid="upload-dropzone">
<div
className={clsx(
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer motion-safe:transition-all',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isDragging
? 'border-primary-500 bg-primary-50'
: 'border-secondary-300 hover:border-secondary-400 bg-secondary-50'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
tabIndex={0}
role="button"
aria-label={t('dms.uploadDropHere')}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleInputChange}
className="hidden"
aria-hidden="true"
/>
<svg className="mx-auto h-10 w-10 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-2 text-sm text-secondary-600">{t('dms.uploadDropHere')}</p>
</div>
{uploads.length > 0 && (
<div className="mt-4 space-y-2" data-testid="upload-progress-list">
{uploads.map((u, i) => (
<div key={i} className="bg-white rounded-md border border-secondary-200 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-secondary-700 truncate">{u.fileName}</span>
{u.error ? (
<span className="text-xs text-danger-600">{u.error}</span>
) : (
<span className="text-xs text-secondary-500">{u.progress}%</span>
)}
</div>
<div className="h-2 bg-secondary-100 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', u.error ? 'bg-danger-500' : 'bg-primary-500')}
style={{ width: `${u.error ? 100 : u.progress}%` }}
role="progressbar"
aria-valuenow={u.error ? 100 : u.progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
</div>
))}
</div>
)}
{isUploading && (
<div className="mt-2 text-sm text-secondary-500" data-testid="upload-indicator">
{t('dms.uploading')}...
</div>
)}
</div>
);
}