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
+200
View File
@@ -0,0 +1,200 @@
/**
* DMS plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
* DMS plugin routes under `/dms/...`.
*/
import { apiClient, apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface DmsFolder {
id: string;
name: string;
parent_id: string | null;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
children?: DmsFolder[];
file_count?: number;
}
export interface DmsFile {
id: string;
folder_id: string | null;
name: string;
mime_type: string;
size: number;
storage_path: string;
checksum?: string | null;
deleted_at: string | null;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
shared_with?: string[];
permissions?: FilePermission[];
}
export interface FilePermission {
id: string;
file_id: string;
user_id: string | null;
group_id: string | null;
permission: 'read' | 'write' | 'admin';
created_at?: string | null;
}
export interface DmsShare {
id: string;
file_id: string;
user_id?: string | null;
group_id?: string | null;
permission: 'read' | 'write';
created_at?: string | null;
}
export interface ShareLink {
id: string;
file_id: string;
token: string;
url: string;
password_protected: boolean;
expires_at: string | null;
created_at?: string | null;
}
export interface EditSession {
id: string;
file_id: string;
session_token: string;
editor_url: string;
expires_at: string;
}
export interface SearchResult {
files: DmsFile[];
total: number;
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface CreateFolderPayload {
name: string;
parent_id?: string | null;
}
export interface UpdateFolderPayload {
name?: string;
parent_id?: string | null;
}
export interface UpdateFilePayload {
name?: string;
folder_id?: string | null;
}
export interface SharePayload {
user_id?: string;
group_id?: string;
permission: 'read' | 'write';
}
export interface BulkMovePayload {
file_ids: string[];
target_folder_id: string | null;
}
export interface BulkDeletePayload {
file_ids: string[];
}
export interface ShareLinkPayload {
password?: string;
expires_at?: string | null;
}
// ─── Folders ───────────────────────────────────────────────────────────────
export function fetchFolders(): Promise<DmsFolder[]> {
return apiGet<DmsFolder[]>('/dms/folders');
}
export function createFolder(payload: CreateFolderPayload): Promise<DmsFolder> {
return apiPost<DmsFolder>('/dms/folders', payload);
}
export function updateFolder(folderId: string, payload: UpdateFolderPayload): Promise<DmsFolder> {
return apiPatch<DmsFolder>(`/dms/folders/${folderId}`, payload);
}
export function deleteFolder(folderId: string): Promise<void> {
return apiDelete<void>(`/dms/folders/${folderId}`);
}
// ─── Files ─────────────────────────────────────────────────────────────────
export function uploadFile(
file: File,
folderId?: string | null
): Promise<DmsFile> {
const form = new FormData();
form.append('file', file);
if (folderId) form.append('folder_id', folderId);
return apiPost<DmsFile>('/dms/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
export function getFile(fileId: string): Promise<DmsFile> {
return apiGet<DmsFile>(`/dms/files/${fileId}`);
}
export function updateFile(fileId: string, payload: UpdateFilePayload): Promise<DmsFile> {
return apiPatch<DmsFile>(`/dms/files/${fileId}`, payload);
}
export function deleteFile(fileId: string): Promise<void> {
return apiDelete<void>(`/dms/files/${fileId}`);
}
export function restoreFile(fileId: string): Promise<DmsFile> {
return apiPost<DmsFile>(`/dms/files/${fileId}/restore`);
}
export function getFilePreviewUrl(fileId: string): string {
return `/api/v1/dms/files/${fileId}/preview`;
}
export function createEditSession(fileId: string): Promise<EditSession> {
return apiPost<EditSession>(`/dms/files/${fileId}/edit-session`);
}
export function shareFile(fileId: string, payload: SharePayload): Promise<DmsShare> {
return apiPost<DmsShare>(`/dms/files/${fileId}/share`, payload);
}
export function removeShare(fileId: string, shareId: string): Promise<void> {
return apiDelete<void>(`/dms/files/${fileId}/share`, { data: { share_id: shareId } });
}
// ─── Search & Shared ───────────────────────────────────────────────────────
export function searchFiles(query: string): Promise<SearchResult> {
const qs = new URLSearchParams({ q: query }).toString();
return apiGet<SearchResult>(`/dms/search?${qs}`);
}
export function getSharedWithMe(): Promise<DmsFile[]> {
return apiGet<DmsFile[]>('/dms/shared-with-me');
}
// ─── Bulk Operations ───────────────────────────────────────────────────────
export function bulkMoveFiles(payload: BulkMovePayload): Promise<void> {
return apiPost<void>('/dms/files/bulk-move', payload);
}
export function bulkDeleteFiles(payload: BulkDeletePayload): Promise<void> {
return apiPost<void>('/dms/files/bulk-delete', payload);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Permissions plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
* the Permissions plugin routes under `/permissions/...`.
*/
import { apiDelete, apiGet, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type PermissionLevel = 'read' | 'write' | 'admin';
export interface FilePermissionEntry {
id: string;
file_id: string;
user_id: string | null;
group_id: string | null;
permission: PermissionLevel;
user_name?: string | null;
group_name?: string | null;
created_at?: string | null;
}
export interface ShareLinkEntry {
id: string;
file_id: string;
token: string;
url: string;
password_protected: boolean;
expires_at: string | null;
created_at?: string | null;
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface GrantPermissionPayload {
user_id?: string;
group_id?: string;
permission: PermissionLevel;
}
export interface CreateShareLinkPayload {
password?: string;
expires_at?: string | null;
}
// ─── File Permissions ──────────────────────────────────────────────────────
export function fetchFilePermissions(fileId: string): Promise<FilePermissionEntry[]> {
return apiGet<FilePermissionEntry[]>(`/permissions/files/${fileId}/permissions`);
}
export function grantPermission(
fileId: string,
payload: GrantPermissionPayload
): Promise<FilePermissionEntry> {
return apiPost<FilePermissionEntry>(`/permissions/files/${fileId}/permissions`, payload);
}
export function revokePermission(fileId: string, userId: string): Promise<void> {
return apiDelete<void>(`/permissions/files/${fileId}/permissions/${userId}`);
}
// ─── Share Links ───────────────────────────────────────────────────────────
export function createShareLink(
fileId: string,
payload: CreateShareLinkPayload
): Promise<ShareLinkEntry> {
return apiPost<ShareLinkEntry>(`/permissions/files/${fileId}/share-link`, payload);
}
export function revokeShareLink(linkId: string): Promise<void> {
return apiDelete<void>(`/permissions/share-links/${linkId}`);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Tags plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
* the Tags plugin routes under `/tags/...`.
*/
import { apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type EntityType = 'company' | 'contact' | 'file' | 'calendar_entry';
export interface Tag {
id: string;
name: string;
color: string;
description?: string | null;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
usage_count?: number;
}
export interface TagAssignment {
id: string;
tag_id: string;
entity_type: EntityType;
entity_id: string;
tag?: Tag;
created_at?: string | null;
}
export interface TagEntity {
entity_type: EntityType;
entity_id: string;
entity_name: string;
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface CreateTagPayload {
name: string;
color?: string;
description?: string | null;
}
export interface UpdateTagPayload {
name?: string;
color?: string;
description?: string | null;
}
export interface AssignTagPayload {
tag_id: string;
entity_type: EntityType;
entity_id: string;
}
export interface UnassignTagPayload {
tag_id: string;
entity_type: EntityType;
entity_id: string;
}
export interface BulkAssignPayload {
tag_ids: string[];
entity_type: EntityType;
entity_ids: string[];
}
// ─── Tags CRUD ─────────────────────────────────────────────────────────────
export function fetchTags(): Promise<Tag[]> {
return apiGet<Tag[]>('/tags');
}
export function createTag(payload: CreateTagPayload): Promise<Tag> {
return apiPost<Tag>('/tags', payload);
}
export function updateTag(tagId: string, payload: UpdateTagPayload): Promise<Tag> {
return apiPatch<Tag>(`/tags/${tagId}`, payload);
}
export function deleteTag(tagId: string): Promise<void> {
return apiDelete<void>(`/tags/${tagId}`);
}
// ─── Tag Assignments ───────────────────────────────────────────────────────
export function assignTag(payload: AssignTagPayload): Promise<TagAssignment> {
return apiPost<TagAssignment>('/tags/assign', payload);
}
export function unassignTag(payload: UnassignTagPayload): Promise<void> {
return apiDelete<void>('/tags/assign', { data: payload });
}
export function bulkAssignTags(payload: BulkAssignPayload): Promise<void> {
return apiPost<void>('/tags/bulk-assign', payload);
}
export function fetchTagEntities(tagId: string): Promise<TagEntity[]> {
return apiGet<TagEntity[]>(`/tags/${tagId}/entities`);
}