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,128 @@
|
||||
/**
|
||||
* Bulk actions bar for DMS file browser.
|
||||
* Shows when files are selected, offers bulk-move and bulk-delete.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { bulkMoveFiles, bulkDeleteFiles, type DmsFolder } from '@/api/dms';
|
||||
|
||||
export interface BulkActionsProps {
|
||||
selectedFileIds: Set<string>;
|
||||
folders: DmsFolder[];
|
||||
onClear: () => void;
|
||||
onActionComplete: () => void;
|
||||
}
|
||||
|
||||
export function BulkActions({
|
||||
selectedFileIds,
|
||||
folders,
|
||||
onClear,
|
||||
onActionComplete,
|
||||
}: BulkActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [targetFolderId, setTargetFolderId] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const selectedCount = selectedFileIds.size;
|
||||
const fileIds = Array.from(selectedFileIds);
|
||||
|
||||
const handleBulkMove = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await bulkMoveFiles({
|
||||
file_ids: fileIds,
|
||||
target_folder_id: targetFolderId || null,
|
||||
});
|
||||
toast.success(t('dms.shared'));
|
||||
setShowMoveDialog(false);
|
||||
onClear();
|
||||
onActionComplete();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await bulkDeleteFiles({ file_ids: fileIds });
|
||||
toast.success(t('dms.deleted'));
|
||||
setShowDeleteDialog(false);
|
||||
onClear();
|
||||
onActionComplete();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const folderOptions = [
|
||||
{ value: '', label: t('dms.allFiles') },
|
||||
...folders.map((f) => ({ value: f.id, label: f.name })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-primary-50 border border-primary-200 rounded-lg"
|
||||
data-testid="bulk-actions-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-primary-700">
|
||||
{selectedCount} {t('dms.selected')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowMoveDialog(true)}
|
||||
>
|
||||
{t('dms.bulkMove')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
{t('dms.bulkDelete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t('dms.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showMoveDialog}
|
||||
title={t('dms.bulkMoveTitle')}
|
||||
message={t('dms.selectTargetFolder')}
|
||||
confirmLabel={t('dms.bulkMove')}
|
||||
onConfirm={handleBulkMove}
|
||||
onCancel={() => setShowMoveDialog(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
title={t('dms.bulkDeleteTitle')}
|
||||
message={t('dms.confirmBulkDelete')}
|
||||
confirmLabel={t('dms.bulkDelete')}
|
||||
variant="danger"
|
||||
onConfirm={handleBulkDelete}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* File grid for DMS file browser.
|
||||
* Displays files with icons, names, sizes, and action buttons.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
|
||||
function getFileIcon(mimeType: string): { path: string; color: string } {
|
||||
if (mimeType === 'application/pdf') {
|
||||
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-danger-500' };
|
||||
}
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return { path: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-accent-500' };
|
||||
}
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
|
||||
return { path: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-success-500' };
|
||||
}
|
||||
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
|
||||
return { path: 'M7 8h10M7 16h10M7 12h6m-7 8h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-warning-500' };
|
||||
}
|
||||
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) {
|
||||
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-primary-500' };
|
||||
}
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) {
|
||||
return { path: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4', color: 'text-secondary-500' };
|
||||
}
|
||||
return { path: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', color: 'text-secondary-400' };
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export interface FileGridProps {
|
||||
files: DmsFile[];
|
||||
selectedFileIds: Set<string>;
|
||||
onToggleSelect: (fileId: string) => void;
|
||||
onFileClick: (file: DmsFile) => void;
|
||||
onFileShare: (file: DmsFile) => void;
|
||||
onFileDelete: (file: DmsFile) => void;
|
||||
onFilePreview: (file: DmsFile) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FileGrid({
|
||||
files,
|
||||
selectedFileIds,
|
||||
onToggleSelect,
|
||||
onFileClick,
|
||||
onFileShare,
|
||||
onFileDelete,
|
||||
onFilePreview,
|
||||
loading = false,
|
||||
}: FileGridProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sortedFiles = useMemo(() => {
|
||||
return [...files].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [files]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" data-testid="file-grid-loading">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg border border-secondary-200 p-4 animate-pulse">
|
||||
<div className="h-12 w-12 bg-secondary-100 rounded mb-3" />
|
||||
<div className="h-4 bg-secondary-100 rounded mb-2" />
|
||||
<div className="h-3 bg-secondary-50 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12" data-testid="file-grid-empty">
|
||||
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" data-testid="file-grid">
|
||||
{sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-4 motion-safe:transition-all cursor-pointer',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<svg className={clsx('w-10 h-10', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.preview')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.share')}
|
||||
title={t('dms.share')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.delete')}
|
||||
title={t('dms.delete')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* File preview modal for DMS.
|
||||
* Renders PDF files in an iframe via the preview endpoint.
|
||||
* Shows file metadata and download button for non-PDF files.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getFilePreviewUrl, type DmsFile } from '@/api/dms';
|
||||
|
||||
export interface FilePreviewModalProps {
|
||||
open: boolean;
|
||||
file: DmsFile | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && file) {
|
||||
setPreviewUrl(getFilePreviewUrl(file.id));
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const isPdf = file.mime_type === 'application/pdf';
|
||||
const isImage = file.mime_type.startsWith('image/');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={file.name}
|
||||
size="xl"
|
||||
data-testid="file-preview-modal"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Badge variant="info">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</Badge>
|
||||
<span className="text-sm text-secondary-500">{formatFileSize(file.size)}</span>
|
||||
{file.created_at && (
|
||||
<span className="text-sm text-secondary-500">
|
||||
{t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPdf && previewUrl && (
|
||||
<div className="border border-secondary-200 rounded-lg overflow-hidden" data-testid="pdf-preview">
|
||||
<iframe
|
||||
src={previewUrl} className="w-full h-[60vh]"
|
||||
title={file.name} aria-label={t('dms.preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage && previewUrl && (
|
||||
<div className="flex justify-center border border-secondary-200 rounded-lg p-4" data-testid="image-preview">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="max-h-[60vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPdf && !isImage && (
|
||||
<div className="text-center py-12" data-testid="no-preview-available">
|
||||
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-secondary-500">{t('dms.preview')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
{previewUrl && (
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={file.name}
|
||||
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 min-h-touch"
|
||||
>
|
||||
{t('dms.download')}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('dms.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Folder tree sidebar for DMS file browser.
|
||||
* Renders a hierarchical tree of folders with expand/collapse and selection.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFolder } from '@/api/dms';
|
||||
|
||||
interface FolderTreeItemProps {
|
||||
folder: DmsFolder;
|
||||
level: number;
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string | null) => void;
|
||||
}
|
||||
|
||||
function FolderTreeItem({ folder, level, selectedFolderId, onSelect }: FolderTreeItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasChildren = folder.children && folder.children.length > 0;
|
||||
const isSelected = selectedFolderId === folder.id;
|
||||
|
||||
return (
|
||||
<li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
|
||||
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isSelected && 'bg-primary-50 text-primary-700 font-medium'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
||||
onClick={() => onSelect(folder.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(folder.id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={folder.name}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
|
||||
aria-label={expanded ? t('dms.folders') : t('dms.folders')}
|
||||
>
|
||||
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />}
|
||||
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{typeof folder.file_count === 'number' && folder.file_count > 0 && (
|
||||
<span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<ul role="group" className="space-y-0.5">
|
||||
{folder.children!.map((child) => (
|
||||
<FolderTreeItem
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={level + 1}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FolderTreeProps {
|
||||
folders: DmsFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string | null) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FolderTree({ folders, selectedFolderId, onSelect, loading = false }: FolderTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 p-2" data-testid="folder-tree-loading">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-8 bg-secondary-100 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={t('dms.folders')} data-testid="folder-tree">
|
||||
<ul role="tree" className="space-y-0.5">
|
||||
<li role="treeitem" aria-selected={selectedFolderId === null}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
|
||||
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium'
|
||||
)}
|
||||
onClick={() => onSelect(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(null);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('dms.allFiles')}
|
||||
>
|
||||
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span>{t('dms.allFiles')}</span>
|
||||
</div>
|
||||
</li>
|
||||
{folders.map((folder) => (
|
||||
<FolderTreeItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Share dialog for DMS files.
|
||||
* Allows sharing with users/groups and creating public share links.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
shareFile,
|
||||
removeShare,
|
||||
type DmsFile,
|
||||
type DmsShare,
|
||||
} from '@/api/dms';
|
||||
import {
|
||||
fetchFilePermissions,
|
||||
grantPermission,
|
||||
revokePermission,
|
||||
createShareLink,
|
||||
revokeShareLink,
|
||||
type FilePermissionEntry,
|
||||
type ShareLinkEntry,
|
||||
} from '@/api/permissions';
|
||||
|
||||
export interface ShareDialogProps {
|
||||
open: boolean;
|
||||
file: DmsFile | null;
|
||||
onClose: () => void;
|
||||
onShared: () => void;
|
||||
}
|
||||
|
||||
export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [shares, setShares] = useState<DmsShare[]>([]);
|
||||
const [permissions, setPermissions] = useState<FilePermissionEntry[]>([]);
|
||||
const [shareLinks, setShareLinks] = useState<ShareLinkEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [shareType, setShareType] = useState<'user' | 'group'>('user');
|
||||
const [shareId, setShareId] = useState('');
|
||||
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
|
||||
const [linkPassword, setLinkPassword] = useState('');
|
||||
const [linkExpiry, setLinkExpiry] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const perms = await fetchFilePermissions(file.id);
|
||||
setPermissions(perms);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && file) {
|
||||
loadData();
|
||||
}
|
||||
}, [open, file, loadData]);
|
||||
|
||||
const handleAddShare = useCallback(async () => {
|
||||
if (!file || !shareId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: { user_id?: string; group_id?: string; permission: 'read' | 'write' } = {
|
||||
permission: sharePermission,
|
||||
};
|
||||
if (shareType === 'user') payload.user_id = shareId;
|
||||
else payload.group_id = shareId;
|
||||
await shareFile(file.id, payload);
|
||||
toast.success(t('dms.shared'));
|
||||
setShareId('');
|
||||
onShared();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [file, shareId, shareType, sharePermission, toast, t, onShared, loadData]);
|
||||
|
||||
const handleRemovePermission = useCallback(async (userId: string) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await revokePermission(file.id, userId);
|
||||
toast.success(t('permissions.revoked'));
|
||||
loadData();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [file, toast, t, loadData]);
|
||||
|
||||
const handleCreateLink = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: { password?: string; expires_at?: string | null } = {};
|
||||
if (linkPassword) payload.password = linkPassword;
|
||||
if (linkExpiry) payload.expires_at = linkExpiry;
|
||||
const link = await createShareLink(file.id, payload);
|
||||
setShareLinks((prev) => [...prev, link]);
|
||||
toast.success(t('permissions.linkCreated'));
|
||||
setLinkPassword('');
|
||||
setLinkExpiry('');
|
||||
onShared();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [file, linkPassword, linkExpiry, toast, t, onShared]);
|
||||
|
||||
const handleCopyLink = useCallback((url: string) => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success(t('dms.linkCopied'));
|
||||
}).catch(() => {
|
||||
toast.error(t('dms.uploadError'));
|
||||
});
|
||||
}, [toast, t]);
|
||||
|
||||
const handleRevokeLink = useCallback(async (linkId: string) => {
|
||||
try {
|
||||
await revokeShareLink(linkId);
|
||||
setShareLinks((prev) => prev.filter((l) => l.id !== linkId));
|
||||
toast.success(t('permissions.linkRevoked'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [toast, t]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('dms.shareWith')}
|
||||
size="lg"
|
||||
data-testid="share-dialog"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Add share section */}
|
||||
<div className="space-y-3" data-testid="share-add-section">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('dms.addShare')}</h3>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select
|
||||
label={t('dms.userOrGroup')}
|
||||
options={[
|
||||
{ value: 'user', label: t('permissions.user') },
|
||||
{ value: 'group', label: t('permissions.group') },
|
||||
]}
|
||||
value={shareType}
|
||||
onChange={(e) => setShareType(e.target.value as 'user' | 'group')}
|
||||
/>
|
||||
<Input
|
||||
label={shareType === 'user' ? t('dms.userId') : t('dms.groupId')}
|
||||
value={shareId}
|
||||
onChange={(e) => setShareId(e.target.value)}
|
||||
placeholder={shareType === 'user' ? t('dms.userId') : t('dms.groupId')}
|
||||
/>
|
||||
<Select
|
||||
label={t('dms.permissionLevel')}
|
||||
options={[
|
||||
{ value: 'read', label: t('permissions.permissionRead') },
|
||||
{ value: 'write', label: t('permissions.permissionWrite') },
|
||||
]}
|
||||
value={sharePermission}
|
||||
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddShare}
|
||||
isLoading={submitting}
|
||||
disabled={!shareId}
|
||||
size="sm"
|
||||
>
|
||||
{t('dms.addShare')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Current permissions */}
|
||||
<div className="space-y-2" data-testid="share-permissions-list">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('permissions.filePermissions')}</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-secondary-500">{t('dms.loading')}...</p>
|
||||
) : permissions.length === 0 ? (
|
||||
<EmptyState title={t('permissions.noPermissions')} />
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{permissions.map((perm) => (
|
||||
<li key={perm.id} className="flex items-center justify-between p-3 bg-secondary-50 rounded-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={perm.permission === 'write' ? 'warning' : 'info'}>
|
||||
{perm.permission === 'read' ? t('permissions.permissionRead') : t('permissions.permissionWrite')}
|
||||
</Badge>
|
||||
<span className="text-sm text-secondary-700">
|
||||
{perm.user_name || perm.group_name || perm.user_id || perm.group_id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => perm.user_id && handleRemovePermission(perm.user_id)}
|
||||
>
|
||||
{t('dms.removeShare')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Public share link section */}
|
||||
<div className="space-y-3 border-t border-secondary-200 pt-4" data-testid="share-link-section">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('dms.shareLink')}</h3>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Input
|
||||
label={t('dms.password')}
|
||||
type="password"
|
||||
value={linkPassword}
|
||||
onChange={(e) => setLinkPassword(e.target.value)}
|
||||
placeholder={t('dms.password')}
|
||||
/>
|
||||
<Input
|
||||
label={t('dms.expiryDate')}
|
||||
type="date"
|
||||
value={linkExpiry}
|
||||
onChange={(e) => setLinkExpiry(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateLink}
|
||||
isLoading={submitting}
|
||||
size="sm"
|
||||
>
|
||||
{t('dms.createShareLink')}
|
||||
</Button>
|
||||
|
||||
{shareLinks.length > 0 && (
|
||||
<ul className="space-y-2" role="list" data-testid="share-links-list">
|
||||
{shareLinks.map((link) => (
|
||||
<li key={link.id} className="flex items-center justify-between p-3 bg-secondary-50 rounded-md">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm text-secondary-700 truncate">{link.url}</span>
|
||||
{link.password_protected && (
|
||||
<Badge variant="warning">{t('permissions.passwordProtected')}</Badge>
|
||||
)}
|
||||
{link.expires_at && (
|
||||
<Badge variant="info">
|
||||
{t('permissions.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCopyLink(link.url)}
|
||||
>
|
||||
{t('dms.copyLink')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeLink(link.id)}
|
||||
>
|
||||
{t('dms.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/companies', labelKey: 'nav.companies', icon: navIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4') },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
|
||||
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
||||
{ to: '/files', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||
{ to: '/email', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||
{ to: '/users', labelKey: 'nav.users', icon: navIcon('M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z') },
|
||||
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import clsx from 'clsx';
|
||||
/**
|
||||
* Bulk tag assignment dialog.
|
||||
* Assigns selected tags to multiple entities at once.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
fetchTags,
|
||||
bulkAssignTags,
|
||||
type Tag,
|
||||
type EntityType,
|
||||
} from '@/api/tags';
|
||||
|
||||
export interface BulkTagDialogProps {
|
||||
open: boolean;
|
||||
entityType: EntityType;
|
||||
entityIds: string[];
|
||||
onClose: () => void;
|
||||
onAssigned: () => void;
|
||||
}
|
||||
|
||||
export function BulkTagDialog({
|
||||
open,
|
||||
entityType,
|
||||
entityIds,
|
||||
onClose,
|
||||
onAssigned,
|
||||
}: BulkTagDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(true);
|
||||
fetchTags()
|
||||
.then((allTags) => {
|
||||
setTags(allTags);
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleToggleTag = useCallback((tagId: string) => {
|
||||
setSelectedTagIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tagId)) {
|
||||
next.delete(tagId);
|
||||
} else {
|
||||
next.add(tagId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
if (selectedTagIds.size === 0 || entityIds.length === 0) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await bulkAssignTags({
|
||||
tag_ids: Array.from(selectedTagIds),
|
||||
entity_type: entityType,
|
||||
entity_ids: entityIds,
|
||||
});
|
||||
toast.success(t('tags.assignSuccess'));
|
||||
setSelectedTagIds(new Set());
|
||||
setSearchQuery('');
|
||||
onAssigned();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [selectedTagIds, entityIds, entityType, toast, t, onAssigned, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('tags.bulkAssignTitle')}
|
||||
size="md"
|
||||
data-testid="bulk-tag-dialog"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-secondary-500">
|
||||
{entityIds.length} {t('tags.selectedEntities')}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
label={t('tags.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('tags.search')}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<EmptyState title={t('tags.noTags')} />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto" role="list" data-testid="bulk-tag-list">
|
||||
{filteredTags.map((tag) => {
|
||||
const isSelected = selectedTagIds.has(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium motion-safe:transition-colors min-h-touch',
|
||||
isSelected
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${tag.name} ${isSelected ? '(selected)' : ''}`}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTagIds.size > 0 && (
|
||||
<p className="text-sm text-primary-600">
|
||||
{selectedTagIds.size} {t('tags.selectTags')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('tags.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
isLoading={submitting}
|
||||
disabled={selectedTagIds.size === 0 || entityIds.length === 0}
|
||||
>
|
||||
{t('tags.bulkAssign')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tag cloud display component.
|
||||
* Renders tags with font sizes proportional to usage count.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import type { Tag } from '@/api/tags';
|
||||
|
||||
export interface TagCloudProps {
|
||||
tags: Tag[];
|
||||
onTagClick?: (tag: Tag) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function TagCloud({ tags, onTagClick, loading = false }: TagCloudProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tagsWithSize = useMemo(() => {
|
||||
if (tags.length === 0) return [];
|
||||
const maxCount = Math.max(...tags.map((tag) => tag.usage_count || 0), 1);
|
||||
const minCount = Math.min(...tags.map((tag) => tag.usage_count || 0), 0);
|
||||
const range = maxCount - minCount || 1;
|
||||
|
||||
return tags.map((tag) => {
|
||||
const count = tag.usage_count || 0;
|
||||
const ratio = (count - minCount) / range;
|
||||
const sizeClass =
|
||||
ratio > 0.75 ? 'text-2xl' :
|
||||
ratio > 0.5 ? 'text-xl' :
|
||||
ratio > 0.25 ? 'text-lg' :
|
||||
'text-base';
|
||||
return { tag, sizeClass };
|
||||
});
|
||||
}, [tags]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud-loading">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-6 bg-secondary-100 rounded animate-pulse" style={{ width: `${60 + i * 20}px` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div data-testid="tag-cloud-empty">
|
||||
<EmptyState title={t('tags.noTags')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud" role="list">
|
||||
{tagsWithSize.map(({ tag, sizeClass }) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
className={`${sizeClass} font-medium text-secondary-700 hover:text-primary-600 motion-safe:transition-colors min-h-touch px-2 py-1 rounded`}
|
||||
aria-label={`${tag.name} (${tag.usage_count || 0} ${t('tags.usageCount')})`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full mr-1 align-middle"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import clsx from 'clsx';
|
||||
/**
|
||||
* Tag picker for entity detail pages.
|
||||
* Shows assigned tags and allows assigning/unassigning tags.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
fetchTags,
|
||||
assignTag,
|
||||
unassignTag,
|
||||
createTag,
|
||||
type Tag,
|
||||
type EntityType,
|
||||
} from '@/api/tags';
|
||||
|
||||
export interface TagPickerProps {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
assignedTags?: Tag[];
|
||||
}
|
||||
|
||||
export function TagPicker({ entityType, entityId, assignedTags: initialAssigned = [] }: TagPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [assignedTags, setAssignedTags] = useState<Tag[]>(initialAssigned);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [newTagColor, setNewTagColor] = useState('#3B82F6');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchTags()
|
||||
.then((tags) => {
|
||||
if (cancelled) return;
|
||||
setAllTags(tags);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [toast]);
|
||||
|
||||
const assignedTagIds = new Set(assignedTags.map((tag) => tag.id));
|
||||
|
||||
const filteredTags = allTags.filter((tag) => {
|
||||
const matchesSearch = tag.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const isAssigned = assignedTagIds.has(tag.id);
|
||||
return matchesSearch && !isAssigned;
|
||||
});
|
||||
|
||||
const handleAssign = useCallback(async (tagId: string) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await assignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
|
||||
const tag = allTags.find((t) => t.id === tagId) || assignedTags.find((t) => t.id === tagId);
|
||||
if (tag) {
|
||||
setAssignedTags((prev) => [...prev, tag]);
|
||||
}
|
||||
toast.success(t('tags.assignSuccess'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [entityType, entityId, allTags, assignedTags, toast, t]);
|
||||
|
||||
const handleUnassign = useCallback(async (tagId: string) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await unassignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
|
||||
setAssignedTags((prev) => prev.filter((tag) => tag.id !== tagId));
|
||||
toast.success(t('tags.unassignSuccess'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [entityType, entityId, toast, t]);
|
||||
|
||||
const handleCreateTag = useCallback(async () => {
|
||||
if (!newTagName.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tag = await createTag({ name: newTagName.trim(), color: newTagColor });
|
||||
setAllTags((prev) => [...prev, tag]);
|
||||
await handleAssign(tag.id);
|
||||
setNewTagName('');
|
||||
setShowCreateForm(false);
|
||||
toast.success(t('tags.createSuccess'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [newTagName, newTagColor, handleAssign, toast, t]);
|
||||
|
||||
const colorOptions = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#F97316', '#EC4899', '#6B7280'];
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="tag-picker">
|
||||
{/* Assigned tags */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('tags.assignedTags')}</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
|
||||
) : assignedTags.length === 0 ? (
|
||||
<EmptyState title={t('tags.noTagsAssigned')} />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.assignedTags')}>
|
||||
{assignedTags.map((tag) => (
|
||||
<div key={tag.id} className="flex items-center">
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-default"
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block mr-1"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => handleUnassign(tag.id)}
|
||||
className="ml-1 text-secondary-400 hover:text-danger-600 min-h-touch min-w-touch"
|
||||
aria-label={t('tags.removeTag')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and assign */}
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={t('tags.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('tags.search')}
|
||||
/>
|
||||
{filteredTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.availableTags')} data-testid="available-tags-list">
|
||||
{filteredTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleAssign(tag.id)}
|
||||
disabled={submitting}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-700 hover:bg-primary-100 hover:text-primary-700 motion-safe:transition-colors min-h-touch disabled:opacity-50"
|
||||
aria-label={t('tags.addTag')}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchQuery && filteredTags.length === 0 && !loading && (
|
||||
<p className="text-sm text-secondary-500">{t('tags.noTags')}</p>
|
||||
)}
|
||||
|
||||
{/* Create new tag */}
|
||||
{!showCreateForm ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
{t('tags.create')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 p-4 border border-secondary-200 rounded-lg" data-testid="create-tag-form">
|
||||
<Input
|
||||
label={t('tags.tagName')}
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
placeholder={t('tags.tagName')}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-secondary-700">{t('tags.tagColor')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewTagColor(color)}
|
||||
className={clsx(
|
||||
'w-6 h-6 rounded-full transition-transform',
|
||||
newTagColor === color ? 'ring-2 ring-offset-2 ring-secondary-400 scale-110' : ''
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`${t('tags.tagColor')}: ${color}`}
|
||||
aria-pressed={newTagColor === color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleCreateTag} isLoading={submitting} disabled={!newTagName.trim()}>
|
||||
{t('tags.save')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreateForm(false)}>
|
||||
{t('tags.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user