diff --git a/.a0/briefings/T08a_briefing.md b/.a0/briefings/T08a_briefing.md new file mode 100644 index 0000000..9c91193 --- /dev/null +++ b/.a0/briefings/T08a_briefing.md @@ -0,0 +1,123 @@ +# T08a: Frontend DMS + Tags + Permissions UI — Implementation Briefing + +## Task +Implement frontend UI for DMS plugin (file browser, upload, preview, share, trash), Tags UI (assign, bulk, tag cloud), and Permissions UI (share links, permission display). + +## Requirements +- F-DMS-01–07: DMS file browser, folder tree, upload, preview, share, trash, search +- F-FILEUI-01–06: File UI components (dropzone, preview modal, share dialog, bulk actions, trash view) +- F-TAG-01–04: Tags UI (assign, bulk assign, tag cloud, tag picker) +- F-PERM-03–05: Permissions UI (share links, permission display) +- F-LINK-01–05: Entity links UI + +## Acceptance Criteria (12 ACs) +1. DMS route /dms renders file browser with folder tree + file grid +2. DMS upload: drag file to dropzone → upload progress → file appears in list +3. DMS file preview modal opens with PDF.js for PDF files +4. DMS share dialog: select user/group, set permission, share created +5. DMS public share link: copy button generates URL, optional password+expiry fields +6. DMS bulk select → bulk-move or bulk-delete actions appear +7. DMS trash view: deleted files list, restore button per file +8. Mail: shared mailbox selector (DO NOT IMPLEMENT — belongs to T08c) +9. Tags: tag picker on company/contact detail → assign/unassign +10. Tags: bulk select entities → bulk-tag dialog +11. Plugin deactivate → plugin route+menu-item disappear from SPA +12. Plugin activate → plugin route+menu-item appear in SPA + +## Backend API Endpoints (already implemented) +### DMS (/api/v1/dms) +- GET /folders — list folder tree +- POST /folders — create folder +- PATCH /folders/{id} — rename/move folder +- DELETE /folders/{id} — delete folder +- POST /files/upload — upload file (multipart) +- GET /files/{id} — get file detail +- PATCH /files/{id} — update file (rename/move) +- DELETE /files/{id} — soft-delete file +- POST /files/{id}/restore — restore from trash +- GET /files/{id}/preview — stream file for preview +- POST /files/{id}/edit-session — create OnlyOffice edit session +- POST /files/{id}/share — share file with user/group +- DELETE /files/{id}/share — remove share +- GET /search?q=text — search files +- GET /shared-with-me — files shared with current user +- POST /files/bulk-move — bulk move files +- POST /files/bulk-delete — bulk delete files + +### Tags (/api/v1/tags) +- GET / — list tags +- POST / — create tag +- PATCH /{id} — update tag +- DELETE /{id} — delete tag +- POST /assign — assign tag to entity +- DELETE /assign — unassign tag +- POST /bulk-assign — bulk assign tags +- GET /{id}/entities — list entities for tag + +### Permissions (/api/v1/permissions) +- GET /files/{id}/permissions — list permissions +- POST /files/{id}/permissions — grant permission +- DELETE /files/{id}/permissions/{user_id} — revoke permission +- POST /files/{id}/share-link — create public share link +- DELETE /share-links/{id} — revoke share link + +## Frontend Architecture (follow existing patterns) +- **Framework:** React + TypeScript + Vite +- **Routing:** react-router-dom (createBrowserRouter, see src/routes/index.tsx) +- **State:** TanStack Query (useQuery/useMutation) +- **HTTP:** axios via src/api/client.ts (apiClient, baseURL /api/v1) +- **API pattern:** See src/api/calendar.ts for plugin API client example +- **UI components:** src/components/ui/ (Button, Card, Input, Modal, Table, Badge, ConfirmDialog, EmptyState, Pagination, Select, Skeleton, Toast) +- **Shared components:** src/components/shared/ (DataGrid, SearchDropdown, Tabs, ActivityFeed) +- **Store:** src/store/ (authStore, uiStore) +- **Layout:** src/components/layout/AppShell (sidebar + main area) +- **i18n:** src/i18n/ (add de.json + en.json keys for DMS/Tags) + +## Files to Create +- `src/api/dms.ts` — DMS API client (types + functions) +- `src/api/tags.ts` — Tags API client +- `src/api/permissions.ts` — Permissions API client +- `src/pages/Dms.tsx` — DMS file browser page (folder tree + file grid) +- `src/pages/DmsTrash.tsx` — DMS trash view +- `src/components/dms/FolderTree.tsx` — folder tree sidebar +- `src/components/dms/FileGrid.tsx` — file grid with icons +- `src/components/dms/UploadDropzone.tsx` — drag-drop upload +- `src/components/dms/FilePreviewModal.tsx` — file preview modal +- `src/components/dms/ShareDialog.tsx` — share dialog +- `src/components/dms/BulkActions.tsx` — bulk select actions +- `src/components/tags/TagPicker.tsx` — tag assign/unassign picker +- `src/components/tags/TagCloud.tsx` — tag cloud display +- `src/components/tags/BulkTagDialog.tsx` — bulk tag assignment dialog +- `src/__tests__/dms/DmsPage.test.tsx` — DMS page tests +- `src/__tests__/dms/UploadDropzone.test.tsx` — upload tests +- `src/__tests__/tags/TagPicker.test.tsx` — tag picker tests +- `src/__tests__/tags/BulkTagDialog.test.tsx` — bulk tag tests +- `src/__tests__/permissions/ShareDialog.test.tsx` — share dialog tests + +## Files to Modify +- `src/routes/index.tsx` — Add /dms, /dms/trash routes +- `src/components/layout/AppShell.tsx` — Add DMS + Tags menu items to sidebar +- `src/pages/CompanyDetail.tsx` — Add TagPicker component +- `src/pages/ContactDetail.tsx` — Add TagPicker component +- `src/i18n/locales/de.json` — Add DMS/Tags translations +- `src/i18n/locales/en.json` — Add DMS/Tags translations + +## Test Spec +- Run: `cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/dms/ src/__tests__/tags/ src/__tests__/permissions/ --reporter=verbose` +- Coverage: `npx vitest run src/__tests__/dms/ src/__tests__/tags/ --coverage` +- Build: `npx vite build` +- Type check: `npx tsc --noEmit` +- Coverage target: 80% +- Follow existing test pattern from src/__tests__/companies/ or src/__tests__/calendar/ + +## Forbidden Patterns +- No inline styles — use Tailwind classes +- No any types — use proper TypeScript interfaces +- No direct fetch() — use apiClient from src/api/client.ts +- No hardcoded strings — use i18n (t() function) +- No Lorem Ipsum — use realistic test data +- No missing loading/error/empty states + +## Estimated Size +- ~600 lines code (pages + components + API clients) +- ~300+ lines tests diff --git a/.a0/current_status.md b/.a0/current_status.md index 3f825c3..cb849be 100644 --- a/.a0/current_status.md +++ b/.a0/current_status.md @@ -1,29 +1,28 @@ # LeoCRM — Current Status **Phase**: 3 (Implementation) **Plan Mode**: implementation_allowed -**Last completed**: T05 — Calendar Plugin Backend (commit 7fbeeda, pushed to Forgejo) -**Date**: 2026-06-30 +**Last completed**: T06 — Mail Plugin Backend (commit f646c59) +**Date**: 2026-07-01 -## Completed Tasks +## Completed Tasks (11/14) - T01: Core Infrastructure + Multi-Tenant + Auth System ✅ - T02: Company + Contact + Import/Export System ✅ - T03: Plugin System Framework ✅ -- T04: DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Shares, Search, Bulk) ✅ -- T05: Calendar Plugin Backend (Appointments, Tasks, Kanban, ICS, Resources, Recurrence) ✅ -- T07a: Frontend Core SPA — Shell, Auth, Routing, i18n, UI Library ✅ -- T07b: Frontend Feature Pages — Companies, Contacts, Settings, Audit, Dashboard, Search ✅ -- T09: KI-Copilot API + Hybrid Workflow Engine Backend ✅ -- T11: Tags Plugin + Permissions Plugin + Entity Links Backend ✅ +- T04: DMS Plugin Backend ✅ +- T05: Calendar Plugin Backend ✅ +- T06: Mail Plugin Backend (IMAP/SMTP, PGP, Rules, Vacation, Delegates) ✅ +- T07a: Frontend Core SPA ✅ +- T07b: Frontend Feature Pages ✅ +- T08b: Frontend Calendar UI ✅ +- T09: KI-Copilot API + Workflow Engine ✅ +- T11: Tags + Permissions + Entity Links Backend ✅ -## T05 Verification -- 69 calendar tests pass (33 AC integration + 36 recurrence unit) -- Coverage: 86.87% (routes.py 82.33%, recurrence.py 90.43%, ics_utils.py 75.21%) -- 481 total tests pass (full regression, 0 failures) -- 0 ruff errors, ruff format clean -- Pushed to Forgejo: 7fbeeda +## Remaining Tasks (3) +- T08a: Frontend DMS + Tags + Permissions UI (free) +- T08c: Frontend Mail UI + Global Search UI (blocked by T06 — now unblocked) +- T10: Monitoring, Performance, Documentation & Env Config (free) -## Next Candidates -- T06: Mail Plugin Backend (IMAP/SMTP, Threading, Templates, Rules, PGP) -- T08a: DMS Frontend (prerequisite T04 ✅ — now unblocked) -- T08b: Calendar Frontend (prerequisite T05 ✅ — now unblocked) -- T10: Monitoring, Performance, Documentation +## Test Summary +- Backend: 527 tests pass (0 failures) +- Mail: 46 tests, 74.56% coverage +- Frontend: 112 tests pass diff --git a/.a0/next_steps.md b/.a0/next_steps.md index b6bb276..be8f9fa 100644 --- a/.a0/next_steps.md +++ b/.a0/next_steps.md @@ -1,8 +1,7 @@ # LeoCRM — Next Steps 1. **User decision needed**: Which task next? - - T06: Mail Plugin Backend (IMAP/SMTP, Threading, Templates, Rules, PGP) - - T08a: DMS Frontend (prerequisite T04 ✅ — now unblocked) - - T08b: Calendar Frontend (prerequisite T05 ✅ — now unblocked) + - T08a: DMS Frontend (prerequisite T04 ✅, T07b ✅ — unblocked) + - T08c: Mail Frontend (prerequisite T06 ✅ — now unblocked) - T10: Monitoring, Performance, Documentation & Environment Config 2. After task selection: delegate to implementation_engineer with briefing 3. After implementation: test_debug_engineer for validation diff --git a/.a0/project_state.json b/.a0/project_state.json index f302799..4998320 100644 --- a/.a0/project_state.json +++ b/.a0/project_state.json @@ -1,10 +1,10 @@ { "project_name": "leocrm", "phase": "phase-3-implementation", - "status": "T08b_complete_phase3_5_tasks_remaining", - "last_commit": "7350739", - "completed_tasks": ["T01","T02","T03","T04","T05","T07a","T08b","T09","T11"], + "status": "T06_complete_3_tasks_remaining", + "last_commit": "f646c59", + "completed_tasks": ["T01","T02","T03","T04","T05","T06","T07a","T07b","T08b","T09","T11"], "current_task": null, - "next_task": "T07b", - "updated_at": "2026-06-30T11:47:00+02:00" + "next_task": "T08a", + "updated_at": "2026-07-01T15:41:00+02:00" } diff --git a/.a0/worklog.md b/.a0/worklog.md index dcd48f5..25c9030 100644 --- a/.a0/worklog.md +++ b/.a0/worklog.md @@ -144,3 +144,13 @@ - ARIA spec: aria-sort value corrected to 'ascending' - **Results:** 112/112 tests pass, tsc clean, vite build successful - **Commit:** e28d11f + +## 2026-07-01 15:41 — T06: Mail Plugin Backend Complete +- **Mail Plugin implementiert:** 8 neue Dateien, 4667 Zeilen +- **14 Models:** mail_accounts, mail_folders, mails, attachments, labels, rules, templates, signatures, vacation_sent_log, seen_by, delegates, send_permissions, pgp_keys, contact_pgp_keys +- **Features:** IMAP sync, SMTP send/reply/forward, threading, templates, rules, vacation (dedup), PGP, shared mailboxes, delegates, send permissions, HTML sanitization, FTS search, contact linking, calendar event creation +- **Tests:** 46/46 pass, 74.56% coverage +- **Regression:** 527/527 pass (0 failures) +- **Ruff:** 0 errors, format clean +- **Commit:** f646c59 +- **Risks:** Coverage 74.56% (target 80%), ILIKE fallback instead of tsvector, ARQ worker not wired diff --git a/frontend/src/__tests__/dms/DmsPage.test.tsx b/frontend/src/__tests__/dms/DmsPage.test.tsx new file mode 100644 index 0000000..f3eca79 --- /dev/null +++ b/frontend/src/__tests__/dms/DmsPage.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@/api/dms', () => ({ + fetchFolders: vi.fn().mockResolvedValue([ + { id: 'f1', name: 'Vertraege', parent_id: null, created_by: 'u1', children: [] }, + { id: 'f2', name: 'Rechnungen', parent_id: null, created_by: 'u1', children: [] }, + ]), + createFolder: vi.fn().mockResolvedValue({ id: 'f3', name: 'Neuer Ordner', parent_id: null, created_by: 'u1' }), + deleteFile: vi.fn().mockResolvedValue(undefined), + searchFiles: vi.fn().mockResolvedValue({ files: [], total: 0 }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +import { DmsPage } from '@/pages/Dms'; + +function renderWithRouter() { + return render(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('DmsPage', () => { + it('renders the DMS page with title', () => { + renderWithRouter(); + expect(screen.getByTestId('dms-page')).toBeInTheDocument(); + }); + + it('renders folder tree container', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByTestId('folder-tree-container')).toBeInTheDocument(); + }); + }); + + it('renders file grid container', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByTestId('file-grid-container')).toBeInTheDocument(); + }); + }); + + it('renders folder tree with folders after loading', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Vertraege')).toBeInTheDocument(); + expect(screen.getByText('Rechnungen')).toBeInTheDocument(); + }); + }); + + it('shows upload button and toggles upload dropzone', async () => { + renderWithRouter(); + const uploadBtn = screen.getByText('Hochladen'); + expect(uploadBtn).toBeInTheDocument(); + fireEvent.click(uploadBtn); + await waitFor(() => { + expect(screen.getByTestId('upload-dropzone')).toBeInTheDocument(); + }); + }); + + it('shows new folder button and toggles form', async () => { + renderWithRouter(); + const folderBtn = screen.getByText('Neuer Ordner'); + expect(folderBtn).toBeInTheDocument(); + fireEvent.click(folderBtn); + await waitFor(() => { + expect(screen.getByTestId('new-folder-form')).toBeInTheDocument(); + }); + }); + + it('renders trash link', async () => { + renderWithRouter(); + expect(screen.getByText('Papierkorb')).toBeInTheDocument(); + }); + + it('renders search input', async () => { + renderWithRouter(); + expect(screen.getByLabelText('Dateien durchsuchen')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/dms/UploadDropzone.test.tsx b/frontend/src/__tests__/dms/UploadDropzone.test.tsx new file mode 100644 index 0000000..ce61f0c --- /dev/null +++ b/frontend/src/__tests__/dms/UploadDropzone.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UploadDropzone } from '@/components/dms/UploadDropzone'; + +const mockUploadFile = vi.fn(); + +vi.mock('@/api/dms', () => ({ + uploadFile: (...args: unknown[]) => mockUploadFile(...args), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mockUploadFile.mockResolvedValue({ id: 'file1', name: 'test.pdf' }); +}); + +describe('UploadDropzone', () => { + it('renders the dropzone area', () => { + render(); + expect(screen.getByTestId('upload-dropzone')).toBeInTheDocument(); + }); + + it('renders drag-and-drop instruction text', () => { + render(); + expect(screen.getByText('Dateien hierher ziehen oder klicken zum Auswaehlen')).toBeInTheDocument(); + }); + + it('opens file picker on click', () => { + render(); + const dropzone = screen.getByTestId('upload-dropzone'); + const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.multiple).toBe(true); + }); + + it('uploads files when input changes', async () => { + const onUploaded = vi.fn(); + render(); + const input = screen.getByTestId('upload-dropzone').querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['test content'], 'report.pdf', { type: 'application/pdf' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => { + expect(mockUploadFile).toHaveBeenCalledWith(file, 'f1'); + }); + await waitFor(() => { + expect(onUploaded).toHaveBeenCalled(); + }); + }); + + it('shows upload progress after file selection', async () => { + render(); + const input = screen.getByTestId('upload-dropzone').querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => { + expect(screen.getByTestId('upload-progress-list')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/permissions/ShareDialog.test.tsx b/frontend/src/__tests__/permissions/ShareDialog.test.tsx new file mode 100644 index 0000000..40002ae --- /dev/null +++ b/frontend/src/__tests__/permissions/ShareDialog.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ShareDialog } from '@/components/dms/ShareDialog'; +import type { DmsFile } from '@/api/dms'; + +const mockFile: DmsFile = { + id: 'file1', + folder_id: null, + name: 'Vertrag.pdf', + mime_type: 'application/pdf', + size: 102400, + storage_path: '/storage/file1', + deleted_at: null, + created_by: 'u1', +}; + +vi.mock('@/api/permissions', () => ({ + fetchFilePermissions: vi.fn().mockResolvedValue([ + { id: 'p1', file_id: 'file1', user_id: 'u2', group_id: null, permission: 'read', user_name: 'Max Mustermann', created_at: '2026-01-01T00:00:00Z' }, + ]), + grantPermission: vi.fn(), + revokePermission: vi.fn().mockResolvedValue(undefined), + createShareLink: vi.fn().mockResolvedValue({ + id: 'sl1', file_id: 'file1', token: 'abc123', + url: 'https://example.com/share/abc123', + password_protected: false, expires_at: null, created_at: '2026-01-01T00:00:00Z', + }), + revokeShareLink: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/api/dms', () => ({ + shareFile: vi.fn().mockResolvedValue({ id: 's1', file_id: 'file1', user_id: 'u3', permission: 'read' }), + removeShare: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +import { fetchFilePermissions, createShareLink } from '@/api/permissions'; +import { shareFile } from '@/api/dms'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ShareDialog', () => { + it('renders dialog when open with file', async () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not render when file is null', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders share add section with user/group select', async () => { + render(); + expect(screen.getByTestId('share-add-section')).toBeInTheDocument(); + expect(screen.getByText('Benutzer oder Gruppe')).toBeInTheDocument(); + }); + + it('renders permissions list after loading', async () => { + render(); + await waitFor(() => { + expect(fetchFilePermissions).toHaveBeenCalledWith('file1'); + }); + await waitFor(() => { + expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + }); + }); + + it('renders public share link section', async () => { + render(); + expect(screen.getByTestId('share-link-section')).toBeInTheDocument(); + }); + + it('creates share link when clicking create button', async () => { + render(); + const createBtn = screen.getByRole('button', { name: 'Link erstellen' }); + fireEvent.click(createBtn); + await waitFor(() => { + expect(createShareLink).toHaveBeenCalledWith('file1', {}); + }); + await waitFor(() => { + expect(screen.getByTestId('share-links-list')).toBeInTheDocument(); + }); + }); + + it('shares file with user when add share is clicked', async () => { + render(); + const userIdInput = screen.getByLabelText('Benutzer-ID'); + fireEvent.change(userIdInput, { target: { value: 'u5' } }); + const addBtn = screen.getByRole('button', { name: 'Freigabe hinzufuegen' }); + fireEvent.click(addBtn); + await waitFor(() => { + expect(shareFile).toHaveBeenCalledWith('file1', { user_id: 'u5', permission: 'read' }); + }); + }); +}); diff --git a/frontend/src/__tests__/tags/BulkTagDialog.test.tsx b/frontend/src/__tests__/tags/BulkTagDialog.test.tsx new file mode 100644 index 0000000..1956879 --- /dev/null +++ b/frontend/src/__tests__/tags/BulkTagDialog.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/api/tags', () => ({ + fetchTags: vi.fn(), + bulkAssignTags: vi.fn(), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +import { BulkTagDialog } from '@/components/tags/BulkTagDialog'; +import { fetchTags, bulkAssignTags } from '@/api/tags'; + +const mockTags = [ + { id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 }, + { id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchTags).mockResolvedValue(mockTags); + vi.mocked(bulkAssignTags).mockResolvedValue(undefined); +}); + +describe('BulkTagDialog', () => { + it('renders dialog when open', async () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('shows selected entity count', async () => { + render(); + expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument(); + }); + + it('loads and displays available tags', async () => { + render(); + await waitFor(() => { + expect(fetchTags).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByRole('button', { name: /VIP-Kunde/ })).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /Lead/ })).toBeInTheDocument(); + }); + + it('toggles tag selection on click', async () => { + render(); + await waitFor(() => { + expect(fetchTags).toHaveBeenCalled(); + }); + const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ }); + fireEvent.click(tagBtn); + await waitFor(() => { + expect(screen.getByText('1 Tags auswaehlen')).toBeInTheDocument(); + }); + }); + + it('calls bulkAssignTags on assign button click', async () => { + const onAssigned = vi.fn(); + const onClose = vi.fn(); + render(); + await waitFor(() => { + expect(fetchTags).toHaveBeenCalled(); + }); + const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ }); + fireEvent.click(tagBtn); + const assignBtn = screen.getByRole('button', { name: 'Tags zuweisen' }); + fireEvent.click(assignBtn); + await waitFor(() => { + expect(bulkAssignTags).toHaveBeenCalledWith({ + tag_ids: ['t1'], + entity_type: 'company', + entity_ids: ['c1', 'c2'], + }); + }); + }); + + it('does not render when closed', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/tags/TagPicker.test.tsx b/frontend/src/__tests__/tags/TagPicker.test.tsx new file mode 100644 index 0000000..8e2d454 --- /dev/null +++ b/frontend/src/__tests__/tags/TagPicker.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { TagPicker } from '@/components/tags/TagPicker'; + +const mockTags = [ + { id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 }, + { id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 }, + { id: 't3', name: 'Aktiv', color: '#10B981', created_by: 'u1', usage_count: 3 }, +]; + +const mockFetchTags = vi.fn(); +const mockAssignTag = vi.fn(); +const mockUnassignTag = vi.fn(); +const mockCreateTag = vi.fn(); + +vi.mock('@/api/tags', () => ({ + fetchTags: (...args: unknown[]) => mockFetchTags(...args), + assignTag: (...args: unknown[]) => mockAssignTag(...args), + unassignTag: (...args: unknown[]) => mockUnassignTag(...args), + createTag: (...args: unknown[]) => mockCreateTag(...args), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mockFetchTags.mockResolvedValue(mockTags); + mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'company', entity_id: 'c1' }); + mockUnassignTag.mockResolvedValue(undefined); + mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' }); +}); + +describe('TagPicker', () => { + it('renders the tag picker container', async () => { + render(); + expect(screen.getByTestId('tag-picker')).toBeInTheDocument(); + }); + + it('renders assigned tags section', async () => { + render(); + expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument(); + }); + + it('shows no tags assigned message when empty', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument(); + }); + }); + + it('loads and displays available tags', async () => { + render(); + await waitFor(() => { + expect(mockFetchTags).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByTestId('available-tags-list')).toBeInTheDocument(); + }); + expect(screen.getByText('VIP-Kunde')).toBeInTheDocument(); + expect(screen.getByText('Lead')).toBeInTheDocument(); + }); + + it('assigns a tag when clicking available tag', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('VIP-Kunde')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('VIP-Kunde')); + await waitFor(() => { + expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'company', entity_id: 'c1' }); + }); + }); + + it('shows create tag form when clicking create button', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Neues Tag')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Neues Tag')); + await waitFor(() => { + expect(screen.getByTestId('create-tag-form')).toBeInTheDocument(); + }); + }); + + it('renders search input for tags', async () => { + render(); + expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/dms.ts b/frontend/src/api/dms.ts new file mode 100644 index 0000000..1f03585 --- /dev/null +++ b/frontend/src/api/dms.ts @@ -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 { + return apiGet('/dms/folders'); +} + +export function createFolder(payload: CreateFolderPayload): Promise { + return apiPost('/dms/folders', payload); +} + +export function updateFolder(folderId: string, payload: UpdateFolderPayload): Promise { + return apiPatch(`/dms/folders/${folderId}`, payload); +} + +export function deleteFolder(folderId: string): Promise { + return apiDelete(`/dms/folders/${folderId}`); +} + +// ─── Files ───────────────────────────────────────────────────────────────── + +export function uploadFile( + file: File, + folderId?: string | null +): Promise { + const form = new FormData(); + form.append('file', file); + if (folderId) form.append('folder_id', folderId); + return apiPost('/dms/files/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +export function getFile(fileId: string): Promise { + return apiGet(`/dms/files/${fileId}`); +} + +export function updateFile(fileId: string, payload: UpdateFilePayload): Promise { + return apiPatch(`/dms/files/${fileId}`, payload); +} + +export function deleteFile(fileId: string): Promise { + return apiDelete(`/dms/files/${fileId}`); +} + +export function restoreFile(fileId: string): Promise { + return apiPost(`/dms/files/${fileId}/restore`); +} + +export function getFilePreviewUrl(fileId: string): string { + return `/api/v1/dms/files/${fileId}/preview`; +} + +export function createEditSession(fileId: string): Promise { + return apiPost(`/dms/files/${fileId}/edit-session`); +} + +export function shareFile(fileId: string, payload: SharePayload): Promise { + return apiPost(`/dms/files/${fileId}/share`, payload); +} + +export function removeShare(fileId: string, shareId: string): Promise { + return apiDelete(`/dms/files/${fileId}/share`, { data: { share_id: shareId } }); +} + +// ─── Search & Shared ─────────────────────────────────────────────────────── + +export function searchFiles(query: string): Promise { + const qs = new URLSearchParams({ q: query }).toString(); + return apiGet(`/dms/search?${qs}`); +} + +export function getSharedWithMe(): Promise { + return apiGet('/dms/shared-with-me'); +} + +// ─── Bulk Operations ─────────────────────────────────────────────────────── + +export function bulkMoveFiles(payload: BulkMovePayload): Promise { + return apiPost('/dms/files/bulk-move', payload); +} + +export function bulkDeleteFiles(payload: BulkDeletePayload): Promise { + return apiPost('/dms/files/bulk-delete', payload); +} diff --git a/frontend/src/api/permissions.ts b/frontend/src/api/permissions.ts new file mode 100644 index 0000000..52ae9df --- /dev/null +++ b/frontend/src/api/permissions.ts @@ -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 { + return apiGet(`/permissions/files/${fileId}/permissions`); +} + +export function grantPermission( + fileId: string, + payload: GrantPermissionPayload +): Promise { + return apiPost(`/permissions/files/${fileId}/permissions`, payload); +} + +export function revokePermission(fileId: string, userId: string): Promise { + return apiDelete(`/permissions/files/${fileId}/permissions/${userId}`); +} + +// ─── Share Links ─────────────────────────────────────────────────────────── + +export function createShareLink( + fileId: string, + payload: CreateShareLinkPayload +): Promise { + return apiPost(`/permissions/files/${fileId}/share-link`, payload); +} + +export function revokeShareLink(linkId: string): Promise { + return apiDelete(`/permissions/share-links/${linkId}`); +} diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts new file mode 100644 index 0000000..6d588c6 --- /dev/null +++ b/frontend/src/api/tags.ts @@ -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 { + return apiGet('/tags'); +} + +export function createTag(payload: CreateTagPayload): Promise { + return apiPost('/tags', payload); +} + +export function updateTag(tagId: string, payload: UpdateTagPayload): Promise { + return apiPatch(`/tags/${tagId}`, payload); +} + +export function deleteTag(tagId: string): Promise { + return apiDelete(`/tags/${tagId}`); +} + +// ─── Tag Assignments ─────────────────────────────────────────────────────── + +export function assignTag(payload: AssignTagPayload): Promise { + return apiPost('/tags/assign', payload); +} + +export function unassignTag(payload: UnassignTagPayload): Promise { + return apiDelete('/tags/assign', { data: payload }); +} + +export function bulkAssignTags(payload: BulkAssignPayload): Promise { + return apiPost('/tags/bulk-assign', payload); +} + +export function fetchTagEntities(tagId: string): Promise { + return apiGet(`/tags/${tagId}/entities`); +} diff --git a/frontend/src/components/dms/BulkActions.tsx b/frontend/src/components/dms/BulkActions.tsx new file mode 100644 index 0000000..2c5883c --- /dev/null +++ b/frontend/src/components/dms/BulkActions.tsx @@ -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; + 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(''); + 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 ( +
+ + {selectedCount} {t('dms.selected')} + +
+ + + +
+ + setShowMoveDialog(false)} + /> + setShowDeleteDialog(false)} + /> +
+ ); +} diff --git a/frontend/src/components/dms/FileGrid.tsx b/frontend/src/components/dms/FileGrid.tsx new file mode 100644 index 0000000..fc83a4d --- /dev/null +++ b/frontend/src/components/dms/FileGrid.tsx @@ -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; + 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 ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+ ))} +
+ ); + } + + if (files.length === 0) { + return ( +
+ +

{t('dms.noFiles')}

+
+ ); + } + + return ( +
+ {sortedFiles.map((file) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + return ( +
onFileClick(file)} + data-testid={`file-card-${file.id}`} + > +
+
+ onToggleSelect(file.id)} + onClick={(e) => e.stopPropagation()} + className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={t('dms.bulkSelect')} + /> + +
+
+

{file.name}

+
+ {formatFileSize(file.size)} + {file.mime_type && {file.mime_type.split('/')[1]?.toUpperCase()}} +
+
+ + + +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/dms/FilePreviewModal.tsx b/frontend/src/components/dms/FilePreviewModal.tsx new file mode 100644 index 0000000..1ec18e9 --- /dev/null +++ b/frontend/src/components/dms/FilePreviewModal.tsx @@ -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(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 ( + +
+
+ {file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')} + {formatFileSize(file.size)} + {file.created_at && ( + + {t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()} + + )} +
+ + {isPdf && previewUrl && ( +
+