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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user