diff --git a/.a0/briefings/T07b_briefing.md b/.a0/briefings/T07b_briefing.md new file mode 100644 index 0000000..4d538ba --- /dev/null +++ b/.a0/briefings/T07b_briefing.md @@ -0,0 +1,164 @@ +# T07b Briefing — Frontend Feature Pages + +## Project Root +/a0/usr/workdir/dev-projects/leocrm + +## Frontend Directory +/a0/usr/workdir/dev-projects/leocrm/frontend/ + +## Task +Build all feature pages for the LeoCRM SPA. T07a (shell, auth, routing, i18n, UI library) is complete. + +## Tech Stack (already set up by T07a) +- React 18 + Vite + TypeScript (strict) +- TanStack Query v5 (hooks in src/api/hooks.ts) +- Zustand (stores in src/store/) +- react-i18next (de/en, src/i18n/) +- React Hook Form + Zod +- Tailwind CSS with design tokens +- Vitest + @testing-library/react + +## Existing API Hooks (src/api/hooks.ts) +- useCompanies(page, pageSize, search) → { items, total, page, page_size } +- useContacts(page, pageSize, search) → { items, total, page, page_size } +- useUsers(page, pageSize) → paginated users +- useCurrentUser() → current user +- useNotifications() → notifications list +- usePlugins() → plugins list +- useLogin(), useLogout(), useSwitchTenant() +- API client: src/api/client.ts (axios, withCredentials, 401→login, 422→validation) + +## Backend API Endpoints +- GET /api/v1/companies?page=1&page_size=25&search=...&industry=...&sort_by=...&sort_order=... +- GET /api/v1/companies/{id} → CompanyDetailResponse (includes contacts[]) +- POST /api/v1/companies +- PATCH /api/v1/companies/{id} +- DELETE /api/v1/companies/{id} +- GET /api/v1/companies/export?format=csv&search=...&industry=... +- POST /api/v1/companies/import (CSV upload) +- GET /api/v1/contacts?page=1&page_size=25&search=... +- GET /api/v1/contacts/{id} → ContactDetailResponse (includes companies[]) +- POST /api/v1/contacts +- PATCH /api/v1/contacts/{id} +- DELETE /api/v1/contacts/{id} +- GET /api/v1/users?page=1&page_size=25 +- GET /api/v1/users/{id} +- POST /api/v1/users +- PATCH /api/v1/users/{id} +- DELETE /api/v1/users/{id} +- GET /api/v1/notifications +- GET /api/v1/plugins +- GET /health + +Note: No dedicated audit log or global search API endpoint exists yet. For audit log, create a frontend page that calls GET /api/v1/audit (may 404 — handle gracefully with empty state). For global search, call useCompanies and useContacts with search param in parallel. + +## Company Schema (from backend) +- name: string (required, 1-100) +- account_number: string? (max 40) +- industry: string? (max 50) +- phone: string? (max 30) +- email: string? (max 255) +- website: string? (max 500) +- description: string? +- CompanyDetailResponse adds: contacts: list[dict] + +## Files to Create/Modify + +### New API Hooks (add to src/api/hooks.ts) +- useCompany(id), useCreateCompany(), useUpdateCompany(), useDeleteCompany() +- useContact(id), useCreateContact(), useUpdateContact(), useDeleteContact() +- useCompanyExport(), useCompanyImport() +- useAuditLog(page, pageSize, filters) +- useGlobalSearch(query, entityTypes) + +### New Pages (src/pages/) +- CompaniesList.tsx — TanStack Table with search/filter/sort/pagination, empty state +- CompanyDetail.tsx — Tabs: overview, contacts, files, activity +- CompanyForm.tsx — Create/edit with React Hook Form + Zod +- ContactsList.tsx — TanStack Table, loading skeleton +- ContactDetail.tsx — Tabs: overview, companies, files, activity +- ContactForm.tsx — Create/edit with multi-company assignment +- AuditLog.tsx — Filterable table (date, user, action, entity) +- GlobalSearchResults.tsx — Filters (entity type, date), highlighting +- SettingsProfile.tsx — Update name, email, password, avatar +- SettingsRoles.tsx — Role editor: create, assign permissions +- SettingsUsers.tsx — User management: list, invite, change role, deactivate + +### Modify Existing Pages +- Dashboard.tsx — Expand with stat cards + recent activity feed +- Settings.tsx — Add tree navigation (Profile, Roles, Users, System) + +### New Components (src/components/) +- SearchDropdown.tsx — Global search dropdown in topbar +- StatCard.tsx — Dashboard stat card +- ActivityFeed.tsx — Recent activity feed +- Tabs.tsx — Reusable tab component for detail pages +- DataGrid.tsx — Wrapper around TanStack Table for list pages +- CsvImportDialog.tsx — CSV upload → preview → import +- UnsavedChangesGuard.tsx — Warn on navigation away with unsaved changes + +### Update Routes (src/routes/index.tsx) +Add routes for all new pages under ProtectedRoute children: +- /companies, /companies/:id, /companies/new, /companies/:id/edit +- /contacts, /contacts/:id, /contacts/new, /contacts/:id/edit +- /audit-log +- /search?q=... +- /settings/profile, /settings/roles, /settings/users + +### Tests (src/__tests__/) +- companies/CompaniesList.test.tsx, CompanyDetail.test.tsx, CompanyForm.test.tsx +- contacts/ContactsList.test.tsx, ContactDetail.test.tsx, ContactForm.test.tsx +- settings/SettingsProfile.test.tsx, SettingsRoles.test.tsx, SettingsUsers.test.tsx +- dashboard/Dashboard.test.tsx +- search/GlobalSearch.test.tsx +- AuditLog.test.tsx + +## Acceptance Criteria (23 total) +1. Companies list renders with TanStack Table (search, filter, sort, pagination) +2. Company detail renders with tabs (overview, contacts, files, activity) +3. Company form validates required fields (name) with Zod +4. Company import: CSV upload → preview → import → success toast +5. Company export: download CSV with current filters +6. Contacts list renders with TanStack Table +7. Contact detail renders with tabs (overview, companies, files, activity) +8. Contact form validates required fields (first_name, last_name, email) with Zod +9. Contact can be assigned to multiple companies +10. Settings renders with tree navigation (Profile, Roles, Users, System) +11. Profile settings: update name, email, password, avatar +12. Role editor: create role, assign permissions, save +13. User management: list users, invite user, change role, deactivate +14. Audit log renders with filterable table (date, user, action, entity) +15. Dashboard renders with stat cards and recent activity feed +16. Global search bar in topbar returns results dropdown +17. Global search results page renders with filters (entity type, date) +18. Search results highlight matched terms +19. Search works across companies, contacts (v1 scope) +20. Companies list: empty state shows helpful message + create button +21. Contacts list: loading state shows skeleton rows +22. Company form: error state shows inline validation errors +23. Settings: unsaved changes warning when navigating away + +## Test Commands +```bash +cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/companies/ src/__tests__/contacts/ src/__tests__/settings/ src/__tests__/dashboard/ src/__tests__/search/ --reporter=verbose +cd /a0/usr/workdir/dev-projects/leocrm/frontend && npm run build +cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx tsc --noEmit +``` + +## Rules +- TypeScript only, no .js files +- No Lorem Ipsum — use real German/English content +- All interactive elements need ARIA labels +- 44px minimum touch targets on mobile +- Use existing UI components from T07a (Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog) +- Use existing i18n setup — add new keys to de.json and en.json +- Use existing API client (src/api/client.ts) — don't create new axios instances +- Coverage target: 80% +- Keep responses under 50 lines +- Use files_create for new files, reference by path + +## Deliverables +1. All files created and tests passing +2. npm run build succeeds with 0 errors +3. tsc --noEmit passes with 0 errors +4. Report: test results, AC coverage, files created, bugs encountered diff --git a/.a0/briefings/T07b_continuation_briefing.md b/.a0/briefings/T07b_continuation_briefing.md new file mode 100644 index 0000000..66254f8 --- /dev/null +++ b/.a0/briefings/T07b_continuation_briefing.md @@ -0,0 +1,158 @@ +# T07b Continuation — Frontend Feature Pages (Part 2) + +## Project Root +/a0/usr/workdir/dev-projects/leocrm + +## Frontend Directory +/a0/usr/workdir/dev-projects/leocrm/frontend/ + +## What's Already Done (DO NOT recreate) + +### API Hooks (src/api/hooks.ts — 432 lines, modified) +16 new hooks already added: useCompany, useCreateCompany, useUpdateCompany, useDeleteCompany, useContact, useCreateContact, useUpdateContact, useDeleteContact, useCompanyExport, useCompanyImport, useAuditLog, useGlobalSearch, plus CRUD for users. + +### Shared Components (src/components/shared/ — all exist) +- `Tabs.tsx` (2055 bytes) — Tab navigation component +- `StatCard.tsx` (1107 bytes) — Dashboard stat card +- `ActivityFeed.tsx` (1372 bytes) — Activity feed list +- `DataGrid.tsx` (6067 bytes) — TanStack Table wrapper with search/sort/pagination +- `SearchDropdown.tsx` (6275 bytes) — Debounced search dropdown with highlighting +- `CsvImportDialog.tsx` (5429 bytes) — CSV upload + preview dialog +- `UnsavedChangesGuard.tsx` (821 bytes) — useBlocker-based unsaved changes warning + +### Dependencies +- @tanstack/react-table@8.21.3 installed + +## What Remains (ALL of this must be created) + +### 1. Feature Pages (src/pages/) + +**Companies:** +- `CompaniesList.tsx` — Use DataGrid component, search/filter/sort/pagination, CSV import (CsvImportDialog) + export buttons, empty state with create button (AC 1, 4, 5, 20) +- `CompanyDetail.tsx` — Tabs: overview, contacts, files, activity (AC 2) +- `CompanyForm.tsx` — RHF + Zod, validate name required, unsaved changes guard (AC 3, 22) + +**Contacts:** +- `ContactsList.tsx` — Use DataGrid, loading skeleton rows, empty state (AC 6, 21) +- `ContactDetail.tsx` — Tabs: overview, companies, files, activity (AC 7) +- `ContactForm.tsx` — RHF + Zod, validate first_name/last_name/email, multi-company assignment (AC 8, 9) + +**Settings:** +- `SettingsProfile.tsx` — Update name, email, password, avatar (AC 11) +- `SettingsRoles.tsx` — Create role, assign permissions, save (AC 12) +- `SettingsUsers.tsx` — List users, invite user, change role, deactivate (AC 13) + +**Other:** +- `AuditLog.tsx` — Filterable table (date, user, action, entity). Call useAuditLog hook. Handle 404 gracefully with empty state (AC 14) +- `GlobalSearchResults.tsx` — Filters (entity type, date), highlight matched terms. Call useGlobalSearch hook (AC 17, 18) + +### 2. Page Updates + +- `Dashboard.tsx` — Replace placeholder with stat cards (StatCard component) + recent activity feed (ActivityFeed component) (AC 15) +- `Settings.tsx` — Add tree navigation (Profile, Roles, Users, System). Render child routes (AC 10) +- `TopBar.tsx` — Add SearchDropdown in topbar for global search (AC 16) + +### 3. Routes (src/routes/index.tsx) + +Add these routes: +``` +/companies → CompaniesList +/companies/:id → CompanyDetail +/companies/new → CompanyForm +/companies/:id/edit → CompanyForm +/contacts → ContactsList +/contacts/:id → ContactDetail +/contacts/new → ContactForm +/contacts/:id/edit → ContactForm +/audit-log → AuditLog +/search → GlobalSearchResults +/settings/profile → SettingsProfile +/settings/roles → SettingsRoles +/settings/users → SettingsUsers +``` + +### 4. i18n Updates (src/i18n/locales/de.json + en.json) + +Add translation keys for all new pages: companies, contacts, settings, audit_log, search, dashboard sections. + +### 5. Tests (src/__tests__/) + +Create test files: +- `companies/CompaniesList.test.tsx` +- `companies/CompanyDetail.test.tsx` +- `companies/CompanyForm.test.tsx` +- `contacts/ContactsList.test.tsx` +- `contacts/ContactDetail.test.tsx` +- `contacts/ContactForm.test.tsx` +- `settings/SettingsProfile.test.tsx` +- `settings/SettingsRoles.test.tsx` +- `settings/SettingsUsers.test.tsx` +- `dashboard/Dashboard.test.tsx` +- `search/GlobalSearch.test.tsx` +- `AuditLog.test.tsx` + +### 6. Verification + +Run these commands and report results: +```bash +cd /a0/usr/workdir/dev-projects/leocrm/frontend +npx vitest run src/__tests__/ --reporter=verbose +npm run build +npx tsc --noEmit +``` + +## Tech Stack +- React 18 + Vite + TypeScript +- TanStack Query v5 (hooks in src/api/hooks.ts) +- Zustand (stores in src/store/) +- react-i18next (de/en locales) +- React Hook Form + Zod +- Tailwind CSS +- Vitest + @testing-library/react +- @tanstack/react-table v8 + +## Existing UI Components (src/components/ui/) +Avatar, Badge, Button, Card, ConfirmDialog, EmptyState, Input, Modal, Pagination, Select, Skeleton, Table, Toast + +## Existing Layout (src/components/layout/) +AppShell, Sidebar, TopBar + +## API Client (src/api/client.ts) +Axios instance with interceptors. Base URL: http://localhost:8000. Auth via session cookie. + +## Backend API Endpoints +``` +GET/POST/PATCH/DELETE /api/v1/companies +GET /api/v1/companies/{id} # includes contacts[] +GET /api/v1/companies/export?format=csv +POST /api/v1/companies/import # CSV upload +GET/POST/PATCH/DELETE /api/v1/contacts +GET /api/v1/contacts/{id} # includes companies[] +GET/POST/PATCH/DELETE /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/notifications +GET /api/v1/plugins +GET /health +``` +Note: No /api/v1/audit endpoint exists yet. useAuditLog hook may 404 — handle gracefully. +Note: No dedicated search endpoint. useGlobalSearch calls useCompanies + useContacts with search param. + +## Company Schema +```python +name: str (required, 1-100 chars) +account_number: str | None (max 40) +industry: str | None (max 50) +phone: str | None (max 30) +email: str | None (max 255) +website: str | None (max 500) +description: str | None +``` + +## Rules +- TypeScript only, no .js files +- No Lorem Ipsum — use real German/English content +- Reuse existing UI components, don't recreate them +- Keep responses under 50 lines — reference files by path +- Use real content, not placeholder text +- All 23 acceptance criteria must be covered +- Test files must use @testing-library/react with vitest diff --git a/.a0/current_status.md b/.a0/current_status.md index 91b2d33..90187e3 100644 --- a/.a0/current_status.md +++ b/.a0/current_status.md @@ -1,16 +1,25 @@ -# Current Status — LeoCRM +# LeoCRM — Current Status -**Phase**: 3 — Implementation -**Status**: T09 COMPLETE — 238/238 tests pass, 84.12% T09 coverage -**Commit**: 14bd4e3 (pushed to Forgejo) -**Date**: 2026-06-29 02:46 CEST +**Date**: 2026-06-29 08:03 +**Phase**: 3 (Implementation) +**Mode**: implementation_allowed ## Completed Tasks -- T01 ✅ Core Infrastructure + Auth (29 tests) -- T02 ✅ Companies + Contacts + Import/Export (27 tests) -- T03 ✅ Plugin System Framework (47 tests) -- T09 ✅ KI-Copilot API + Workflow Engine (135 tests: 30 AC + 105 coverage) +- T01: Core infrastructure + auth + multi-tenant + RLS (commit dd16940^) +- T02: Companies + contacts + import/export + N:M + soft-delete + GDPR + FTS (commit dd16940) +- T03: Plugin system framework + lifecycle + migrations + event bus + DI (commit 9678344) +- T09: KI-Copilot API + Hybrid Workflow Engine + LLM client + event-triggered workflows (commit 851e799) +- T07a: Frontend Core SPA — shell + auth + routing + i18n + UI library + a11y (commit 22976ab) -## Next Action -- Delegate T07a (Frontend SPA Shell — React 18 + Vite + TypeScript) -- T07a briefing ready at .a0/briefings/T07a_briefing.md +## T07a Results +- 111 tests passing (20 test files) +- tsc --noEmit: 0 errors +- npm run build: success (471KB JS, 24KB CSS) +- 66 files, 8598 insertions +- Pushed to Forgejo: https://forgejo.media-on.de/Leopoldadmin/leocrm + +## In Progress +- Nothing currently active + +## Next Task +- T07b: Frontend Feature Pages (Companies, Contacts, Settings, Audit Log, Dashboard, Global Search) diff --git a/.a0/next_steps.md b/.a0/next_steps.md index 29c16a2..ac9a971 100644 --- a/.a0/next_steps.md +++ b/.a0/next_steps.md @@ -1,21 +1,11 @@ -# Next Steps — LeoCRM +# LeoCRM — Next Steps -## Current: T09 COMPLETE ✅ (commit 14bd4e3, pushed) +1. **T07b**: Frontend Feature Pages — Companies, Contacts, Settings, Audit Log, Dashboard, Global Search + - Dependencies: T01, T02, T07a (all complete) + - Subagent: implementation_engineer + - Estimated: ~1200 lines + - Acceptance criteria: 23 (from task_graph.json) -## Phase 3 Implementation Progress -- T01 ✅ Core Infrastructure + Multi-Tenant + Auth (commit 7a7daf8) -- T02 ✅ Company + Contact + Import/Export (commit 6bf0746) -- T03 ✅ Plugin System Framework (commit 7a5a48f) -- T09 ✅ KI-Copilot API + Workflow Engine (commit 14bd4e3) - -## Next: T07a — Frontend SPA Shell -- Deps: T01 ✅ -- Reqs: 23 features, 27 ACs -- Stack: React 18 + Vite + TypeScript + React Router v6 + TanStack Query v5 + Zustand + react-i18next + Tailwind CSS + Vitest -- Briefing ready: .a0/briefings/T07a_briefing.md - -## After T07a: T07b (Frontend Feature Pages) -## After T07b: T10 (Monitoring, Performance, Documentation) - -## v1 Execution Order -T01 ✅ → T02 ✅ → T03 ✅ → T09 ✅ → T07a → T07b → T10 +2. **After T07b**: Continue with remaining v1 tasks per task_graph.json +3. **Quality Gate**: After all implementation tasks → quality_reviewer review +4. **Phase transition**: Implementation → Test (Phase 4) requires user approval diff --git a/.a0/project_state.json b/.a0/project_state.json new file mode 100644 index 0000000..a05b193 --- /dev/null +++ b/.a0/project_state.json @@ -0,0 +1,16 @@ +{ + "project_name": "leocrm", + "phase": "phase-3-implementation", + "status": "T07a_complete_111_tests_build_pass_pushed_22976ab", + "last_commit": "22976ab", + "completed_tasks": [ + "T01", + "T02", + "T03", + "T09", + "T07a" + ], + "current_task": null, + "next_task": "T07b", + "updated_at": "2026-06-29T08:04:01+02:00" +} \ No newline at end of file diff --git a/.a0/worklog.md b/.a0/worklog.md index 827d80f..ce922c5 100644 --- a/.a0/worklog.md +++ b/.a0/worklog.md @@ -84,3 +84,19 @@ - Pushed to Forgejo: 7a5a48f..14bd4e3 ### Next: T07a (Frontend SPA Shell — React 18) + +## 2026-06-29 08:03 — T07a Complete +- **Task**: T07a — Frontend Core SPA (Shell, Auth, Routing, i18n, UI Library, Accessibility) +- **Commit**: 22976ab (pushed to Forgejo) +- **Tests**: 111/111 passing (20 test files) +- **tsc**: 0 errors +- **Build**: Success (471KB JS, 24KB CSS gzipped) +- **Files**: 66 files, 8598 insertions +- **Fixes applied by orchestrator**: + - Login form aria-label for role=form accessibility + - Avatar img alt="" to prevent duplicate role=img + - Avatar test null-safety with non-null assertion + - index.css border-border → border-secondary-200 (Tailwind class missing) + - .gitignore created to exclude node_modules/dist + - Remote URL fixed from agent-zero to Forgejo leocrm repo +- **Subagent**: implementation_engineer (hit context cap at ~90%, orchestrator completed remaining fixes) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e116901..e7f3dbd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.56.0", + "@tanstack/react-table": "^8.21.3", "axios": "^1.7.7", "clsx": "^2.1.1", "i18next": "^23.14.0", @@ -1332,6 +1333,37 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 38b14dc..a686da4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,35 +12,36 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.0", + "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.56.0", - "zustand": "^4.5.5", - "react-i18next": "^15.0.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.7.7", + "clsx": "^2.1.1", "i18next": "^23.14.0", "i18next-browser-languagedetector": "^8.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-i18next": "^15.0.0", + "react-router-dom": "^6.26.0", "zod": "^3.23.8", - "@hookform/resolvers": "^3.9.0", - "axios": "^1.7.7", - "clsx": "^2.1.1" + "zustand": "^4.5.5" }, "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^2.1.0", + "autoprefixer": "^10.4.20", + "identity-obj-proxy": "^3.0.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", "typescript": "^5.6.0", "vite": "^5.4.0", - "vitest": "^2.1.0", - "@vitest/coverage-v8": "^2.1.0", - "jsdom": "^25.0.0", - "@testing-library/react": "^16.0.1", - "@testing-library/jest-dom": "^6.5.0", - "@testing-library/user-event": "^14.5.2", - "tailwindcss": "^3.4.13", - "postcss": "^8.4.47", - "autoprefixer": "^10.4.20", - "identity-obj-proxy": "^3.0.0" + "vitest": "^2.1.0" } } diff --git a/frontend/src/__tests__/AuditLog.test.tsx b/frontend/src/__tests__/AuditLog.test.tsx new file mode 100644 index 0000000..b765ae2 --- /dev/null +++ b/frontend/src/__tests__/AuditLog.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuditLogPage } from '@/pages/AuditLog'; + +let mockReturnValue: any = { + data: { + items: [ + { timestamp: '2025-06-29T10:00:00Z', user: 'anna.schmidt', action: 'create', entity: 'company', entity_id: '1', details: 'Firma erstellt' }, + ], + total: 1, + }, + isLoading: false, + isError: false, +}; + +vi.mock('@/api/hooks', () => ({ + useAuditLog: () => mockReturnValue, +})); + +describe('AuditLogPage', () => { + beforeEach(() => { + mockReturnValue = { + data: { + items: [ + { timestamp: '2025-06-29T10:00:00Z', user: 'anna.schmidt', action: 'create', entity: 'company', entity_id: '1', details: 'Firma erstellt' }, + ], + total: 1, + }, + isLoading: false, + isError: false, + }; + }); + + it('renders audit log page', () => { + render(); + expect(screen.getByTestId('audit-log-page')).toBeInTheDocument(); + }); + + it('renders audit log table via DataGrid', () => { + render(); + expect(screen.getByTestId('audit-log-grid')).toBeInTheDocument(); + }); + + it('handles 404 error gracefully', () => { + mockReturnValue = { data: undefined, isLoading: false, isError: true }; + render(); + expect(screen.getByText(/nicht verf/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/companies/CompaniesList.test.tsx b/frontend/src/__tests__/companies/CompaniesList.test.tsx new file mode 100644 index 0000000..4c1c550 --- /dev/null +++ b/frontend/src/__tests__/companies/CompaniesList.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { CompaniesListPage } from '@/pages/CompaniesList'; + +vi.mock('@/api/hooks', () => ({ + useCompanies: () => ({ + data: { + items: [ + { id: '1', name: 'TestCorp GmbH', email: 'info@test.de', phone: '+49 30 123', industry: 'IT', city: 'Berlin', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, + ], + total: 1, + }, + isLoading: false, + }), + useCompanyExport: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCompanyImport: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@/store/uiStore', () => ({ + useUIStore: () => ({ addToast: vi.fn(), removeToast: vi.fn() }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +describe('CompaniesListPage', () => { + it('renders the page with title', () => { + render(); + expect(screen.getByTestId('companies-list-page')).toBeInTheDocument(); + }); + + it('renders DataGrid for company list', () => { + render(); + expect(screen.getByTestId('companies-grid')).toBeInTheDocument(); + }); + + it('renders company data in grid', () => { + render(); + expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/companies/CompanyDetail.test.tsx b/frontend/src/__tests__/companies/CompanyDetail.test.tsx new file mode 100644 index 0000000..ed3ef97 --- /dev/null +++ b/frontend/src/__tests__/companies/CompanyDetail.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { CompanyDetailPage } from '@/pages/CompanyDetail'; + +vi.mock('@/api/hooks', () => ({ + useCompany: () => ({ + data: { + id: '1', name: 'TestCorp GmbH', email: 'info@testcorp.de', + phone: '+49 30 12345678', website: 'https://testcorp.de', + industry: 'IT', address: 'Berlin', city: 'Berlin', + zip_code: '10115', country: 'Deutschland', + created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', + contacts: [], + }, + isLoading: false, + }), +})); + +describe('CompanyDetailPage', () => { + it('renders the detail page', () => { + render(); + expect(screen.getByTestId('company-detail-page')).toBeInTheDocument(); + }); + + it('renders tabs for detail sections', () => { + render(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/companies/CompanyForm.test.tsx b/frontend/src/__tests__/companies/CompanyForm.test.tsx new file mode 100644 index 0000000..25d7219 --- /dev/null +++ b/frontend/src/__tests__/companies/CompanyForm.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { CompanyFormPage } from '@/pages/CompanyForm'; + +vi.mock('@/api/hooks', () => ({ + useCompany: () => ({ data: undefined, isLoading: false }), + useCreateCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpdateCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +vi.mock('@/components/shared/UnsavedChangesGuard', () => ({ + UnsavedChangesGuard: () => null, +})); + +describe('CompanyFormPage', () => { + it('renders form with name field', () => { + render(); + expect(screen.getByLabelText(/Firmenname/)).toBeInTheDocument(); + }); + + it('renders email and phone fields', () => { + render(); + expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument(); + }); + + it('submit button is present', () => { + render(); + expect(screen.getByTestId('company-submit-btn')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/contacts/ContactDetail.test.tsx b/frontend/src/__tests__/contacts/ContactDetail.test.tsx new file mode 100644 index 0000000..3a6271c --- /dev/null +++ b/frontend/src/__tests__/contacts/ContactDetail.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ContactDetailPage } from '@/pages/ContactDetail'; + +vi.mock('@/api/hooks', () => ({ + useContact: () => ({ + data: { + id: '1', first_name: 'Max', last_name: 'Mustermann', + email: 'max@example.com', phone: '+49 30 12345678', + position: 'CEO', company_id: '1', company_name: 'TestCorp GmbH', + created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', + }, + isLoading: false, + }), +})); + +describe('ContactDetailPage', () => { + it('renders the detail page', () => { + render(); + expect(screen.getByTestId('contact-detail-page')).toBeInTheDocument(); + }); + + it('renders tabs', () => { + render(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/contacts/ContactForm.test.tsx b/frontend/src/__tests__/contacts/ContactForm.test.tsx new file mode 100644 index 0000000..f4b578c --- /dev/null +++ b/frontend/src/__tests__/contacts/ContactForm.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ContactFormPage } from '@/pages/ContactForm'; + +vi.mock('@/api/hooks', () => ({ + useContact: () => ({ data: undefined, isLoading: false }), + useCreateContact: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpdateContact: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCompanies: () => ({ data: { items: [{ id: '1', name: 'TestCorp GmbH' }], total: 1 }, isLoading: false }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +vi.mock('@/components/shared/UnsavedChangesGuard', () => ({ + UnsavedChangesGuard: () => null, +})); + +describe('ContactFormPage', () => { + it('renders form with first name field', () => { + render(); + expect(screen.getByLabelText(/Vorname/)).toBeInTheDocument(); + }); + + it('renders form with last name field', () => { + render(); + expect(screen.getByLabelText(/Nachname/)).toBeInTheDocument(); + }); + + it('renders email field', () => { + render(); + expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/contacts/ContactsList.test.tsx b/frontend/src/__tests__/contacts/ContactsList.test.tsx new file mode 100644 index 0000000..273eda0 --- /dev/null +++ b/frontend/src/__tests__/contacts/ContactsList.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ContactsListPage } from '@/pages/ContactsList'; + +vi.mock('@/api/hooks', () => ({ + useContacts: () => ({ + data: { + items: [ + { id: '1', first_name: 'Max', last_name: 'Mustermann', email: 'max@test.de', phone: '+49 30 123', company_name: 'TestCorp GmbH', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, + ], + total: 1, + }, + isLoading: false, + }), + useDeleteContact: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +describe('ContactsListPage', () => { + it('renders the page', () => { + render(); + expect(screen.getByTestId('contacts-list-page')).toBeInTheDocument(); + }); + + it('renders DataGrid for contacts', () => { + render(); + expect(screen.getByTestId('contacts-grid')).toBeInTheDocument(); + }); + + it('renders contact data in grid', () => { + render(); + expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/dashboard/Dashboard.test.tsx b/frontend/src/__tests__/dashboard/Dashboard.test.tsx new file mode 100644 index 0000000..4233085 --- /dev/null +++ b/frontend/src/__tests__/dashboard/Dashboard.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { DashboardPage } from '@/pages/Dashboard'; + +vi.mock('@/api/hooks', () => ({ + useCompanies: () => ({ data: { items: [], total: 24 }, isLoading: false }), + useContacts: () => ({ data: { items: [], total: 156 }, isLoading: false }), + useAuditLog: () => ({ + data: { + items: [ + { timestamp: '2025-06-29T10:00:00Z', user: 'anna.schmidt', action: 'create', entity: 'company', entity_id: '1', details: 'Firma erstellt' }, + ], + total: 1, + }, + isError: false, + }), +})); + +describe('DashboardPage', () => { + it('renders dashboard page', () => { + render(); + expect(screen.getByTestId('dashboard-page')).toBeInTheDocument(); + }); + + it('renders stat cards', () => { + render(); + expect(screen.getByTestId('stat-companies')).toBeInTheDocument(); + expect(screen.getByTestId('stat-contacts')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/search/GlobalSearch.test.tsx b/frontend/src/__tests__/search/GlobalSearch.test.tsx new file mode 100644 index 0000000..c25dc94 --- /dev/null +++ b/frontend/src/__tests__/search/GlobalSearch.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults'; + +vi.mock('@/api/hooks', () => ({ + useGlobalSearch: () => ({ + data: [ + { id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' }, + { id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' }, + ], + isLoading: false, + }), +})); + +describe('GlobalSearchResultsPage', () => { + it('renders search page', () => { + render(); + expect(screen.getByTestId('global-search-page')).toBeInTheDocument(); + }); + + it('renders search input field', () => { + render(); + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/settings/SettingsProfile.test.tsx b/frontend/src/__tests__/settings/SettingsProfile.test.tsx new file mode 100644 index 0000000..5cfc7f2 --- /dev/null +++ b/frontend/src/__tests__/settings/SettingsProfile.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsProfilePage } from '@/pages/SettingsProfile'; + +vi.mock('@/store/authStore', () => ({ + useAuthStore: () => ({ + user: { + id: '1', email: 'test@test.de', first_name: 'Max', last_name: 'Mustermann', + }, + }), +})); + +vi.mock('@/api/hooks', () => ({ + useUpdateUser: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +describe('SettingsProfilePage', () => { + it('renders profile page', () => { + render(); + expect(screen.getByTestId('settings-profile-page')).toBeInTheDocument(); + }); + + it('renders first name field', () => { + render(); + expect(screen.getByLabelText(/Vorname/)).toBeInTheDocument(); + }); + + it('renders email field', () => { + render(); + expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/settings/SettingsRoles.test.tsx b/frontend/src/__tests__/settings/SettingsRoles.test.tsx new file mode 100644 index 0000000..3f518c9 --- /dev/null +++ b/frontend/src/__tests__/settings/SettingsRoles.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsRolesPage } from '@/pages/SettingsRoles'; + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +describe('SettingsRolesPage', () => { + it('renders roles page', () => { + render(); + expect(screen.getByTestId('settings-roles-page')).toBeInTheDocument(); + }); + + it('renders role editor with create button', () => { + render(); + expect(screen.getByTestId('create-role-btn')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/settings/SettingsUsers.test.tsx b/frontend/src/__tests__/settings/SettingsUsers.test.tsx new file mode 100644 index 0000000..5e34553 --- /dev/null +++ b/frontend/src/__tests__/settings/SettingsUsers.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsUsersPage } from '@/pages/SettingsUsers'; + +vi.mock('@/api/hooks', () => ({ + useUsers: () => ({ + data: { + items: [ + { id: '1', email: 'admin@test.de', first_name: 'Admin', last_name: 'User', role: 'admin', is_active: true }, + ], + total: 1, + }, + isLoading: false, + }), + useCreateUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpdateUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteUser: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); + +describe('SettingsUsersPage', () => { + it('renders users page', () => { + render(); + expect(screen.getByTestId('settings-users-page')).toBeInTheDocument(); + }); + + it('renders user list with at least one user', () => { + render(); + expect(screen.getAllByText(/Admin/).length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/__tests__/shell/AppShell.test.tsx b/frontend/src/__tests__/shell/AppShell.test.tsx index cc54974..77f4384 100644 --- a/frontend/src/__tests__/shell/AppShell.test.tsx +++ b/frontend/src/__tests__/shell/AppShell.test.tsx @@ -7,6 +7,7 @@ import { AppShell } from '@/components/layout/AppShell'; vi.mock('@/api/hooks', () => ({ useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }), useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }), + useGlobalSearch: () => ({ data: [], isLoading: false }), })); function renderWithRouter(initialPath = '/dashboard') { diff --git a/frontend/src/__tests__/shell/TopBar.test.tsx b/frontend/src/__tests__/shell/TopBar.test.tsx index cfbb2bc..f9b1cfe 100644 --- a/frontend/src/__tests__/shell/TopBar.test.tsx +++ b/frontend/src/__tests__/shell/TopBar.test.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from '@/store/authStore'; vi.mock('@/api/hooks', () => ({ useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }), useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }), + useGlobalSearch: () => ({ data: [], isLoading: false }), })); function renderTopBar() { diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 0dba28b..ec051a9 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiPost, apiGet, apiPatch, apiDelete } from './client'; +import { apiPost, apiGet, apiPatch, apiDelete, apiClient } from './client'; import { useAuthStore } from '@/store/authStore'; export interface LoginPayload { @@ -23,6 +23,57 @@ export interface PaginatedResponse { page_size: number; } +export interface Company { + id: string; + name: string; + account_number?: string | null; + industry?: string | null; + phone?: string | null; + email?: string | null; + website?: string | null; + description?: string | null; + created_at?: string; + updated_at?: string; +} + +export interface CompanyDetail extends Company { + contacts?: Contact[]; +} + +export interface Contact { + id: string; + first_name: string; + last_name: string; + email: string; + phone?: string | null; + position?: string | null; + company_ids?: string[]; + created_at?: string; + updated_at?: string; +} + +export interface ContactDetail extends Contact { + companies?: Company[]; +} + +export interface AuditLogEntry { + id: string; + timestamp: string; + user: string; + action: string; + entity: string; + entity_id?: string; + details?: string; +} + +export interface SearchResult { + type: 'company' | 'contact'; + id: string; + name: string; + description?: string; + url: string; +} + export function useLogin() { const { setUser, setError } = useAuthStore(); return useMutation({ @@ -100,13 +151,126 @@ export function useUsers(page = 1, pageSize = 25) { }); } -export function useCompanies(page = 1, pageSize = 25, search?: string) { +export function useUser(id?: string) { + return useQuery({ + queryKey: ['users', id], + queryFn: () => apiGet(`/users/${id}`), + enabled: !!id, + }); +} + +export function useCreateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: any) => apiPost('/users', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); +} + +export function useUpdateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => + apiPatch(`/users/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); +} + +export function useDeleteUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/users/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); +} + +export function useCompanies(page = 1, pageSize = 25, search?: string, industry?: string, sortBy?: string, sortOrder?: string) { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (search) params.set('search', search); + if (industry) params.set('industry', industry); + if (sortBy) params.set('sort_by', sortBy); + if (sortOrder) params.set('sort_order', sortOrder); return useQuery({ - queryKey: ['companies', page, pageSize, search], + queryKey: ['companies', page, pageSize, search, industry, sortBy, sortOrder], queryFn: () => - apiGet>(`/companies?${params.toString()}`), + apiGet>(`/companies?${params.toString()}`), + }); +} + +export function useCompany(id?: string) { + return useQuery({ + queryKey: ['companies', id], + queryFn: () => apiGet(`/companies/${id}`), + enabled: !!id, + }); +} + +export function useCreateCompany() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/companies', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies'] }); + }, + }); +} + +export function useUpdateCompany() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + apiPatch(`/companies/${id}`, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['companies'] }); + queryClient.invalidateQueries({ queryKey: ['companies', variables.id] }); + }, + }); +} + +export function useDeleteCompany() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/companies/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies'] }); + }, + }); +} + +export function useCompanyExport(search?: string, industry?: string) { + return useMutation({ + mutationFn: async () => { + const params = new URLSearchParams({ format: 'csv' }); + if (search) params.set('search', search); + if (industry) params.set('industry', industry); + const response = await apiClient.get(`/companies/export?${params.toString()}`, { + responseType: 'blob', + }); + return response.data; + }, + }); +} + +export function useCompanyImport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post('/companies/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies'] }); + }, }); } @@ -116,7 +280,113 @@ export function useContacts(page = 1, pageSize = 25, search?: string) { return useQuery({ queryKey: ['contacts', page, pageSize, search], queryFn: () => - apiGet>(`/contacts?${params.toString()}`), + apiGet>(`/contacts?${params.toString()}`), + }); +} + +export function useContact(id?: string) { + return useQuery({ + queryKey: ['contacts', id], + queryFn: () => apiGet(`/contacts/${id}`), + enabled: !!id, + }); +} + +export function useCreateContact() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial & { company_ids?: string[] }) => + apiPost('/contacts', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['contacts'] }); + }, + }); +} + +export function useUpdateContact() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial & { company_ids?: string[] } }) => + apiPatch(`/contacts/${id}`, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['contacts'] }); + queryClient.invalidateQueries({ queryKey: ['contacts', variables.id] }); + }, + }); +} + +export function useDeleteContact() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/contacts/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['contacts'] }); + }, + }); +} + +export function useAuditLog(page = 1, pageSize = 25, filters?: { user?: string; action?: string; entity?: string; dateFrom?: string; dateTo?: string }) { + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (filters?.user) params.set('user', filters.user); + if (filters?.action) params.set('action', filters.action); + if (filters?.entity) params.set('entity', filters.entity); + if (filters?.dateFrom) params.set('date_from', filters.dateFrom); + if (filters?.dateTo) params.set('date_to', filters.dateTo); + return useQuery({ + queryKey: ['auditLog', page, pageSize, filters], + queryFn: () => + apiGet>(`/audit?${params.toString()}`), + retry: false, + }); +} + +export function useGlobalSearch(query: string, entityTypes?: string[]) { + return useQuery({ + queryKey: ['globalSearch', query, entityTypes], + queryFn: async (): Promise => { + if (!query.trim()) return []; + const results: SearchResult[] = []; + const types = entityTypes && entityTypes.length > 0 ? entityTypes : ['company', 'contact']; + const tasks: Promise[] = []; + if (types.includes('company')) { + tasks.push( + apiGet>(`/companies?page=1&page_size=10&search=${encodeURIComponent(query)}`) + .then((data) => { + for (const item of data.items) { + results.push({ + type: 'company', + id: item.id, + name: item.name, + description: item.industry || item.email || '', + url: `/companies/${item.id}`, + }); + } + }) + .catch(() => {}) + ); + } + if (types.includes('contact')) { + tasks.push( + apiGet>(`/contacts?page=1&page_size=10&search=${encodeURIComponent(query)}`) + .then((data) => { + for (const item of data.items) { + results.push({ + type: 'contact', + id: item.id, + name: `${item.first_name} ${item.last_name}`, + description: item.email || item.phone || '', + url: `/contacts/${item.id}`, + }); + } + }) + .catch(() => {}) + ); + } + await Promise.all(tasks); + return results; + }, + enabled: query.trim().length > 0, + staleTime: 30 * 1000, }); } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 280a688..0ac9ea7 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -8,6 +8,7 @@ import { useTenant } from '@/hooks/useTenant'; import { useLogout } from '@/api/hooks'; import { Avatar } from '@/components/ui/Avatar'; import { Badge } from '@/components/ui/Badge'; +import { SearchDropdown } from '@/components/shared/SearchDropdown'; export function TopBar() { const { t } = useTranslation(); @@ -119,16 +120,8 @@ export function TopBar() { {/* Search */} -
- - +
+
diff --git a/frontend/src/components/shared/ActivityFeed.tsx b/frontend/src/components/shared/ActivityFeed.tsx new file mode 100644 index 0000000..0c5bdce --- /dev/null +++ b/frontend/src/components/shared/ActivityFeed.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Card } from '@/components/ui/Card'; +import { Avatar } from '@/components/ui/Avatar'; + +export interface ActivityItem { + id: string; + user: string; + action: string; + time: string; + avatarUrl?: string | null; +} + +export interface ActivityFeedProps { + activities: ActivityItem[]; + title?: string; + maxItems?: number; +} + +export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) { + const visible = activities.slice(0, maxItems); + return ( + + {visible.length === 0 ? ( +

Keine Aktivitäten vorhanden.

+ ) : ( +
    + {visible.map((activity) => ( +
  • + +
    + {activity.user} + {activity.action} + {activity.time} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/shared/CsvImportDialog.tsx b/frontend/src/components/shared/CsvImportDialog.tsx new file mode 100644 index 0000000..40e4a2a --- /dev/null +++ b/frontend/src/components/shared/CsvImportDialog.tsx @@ -0,0 +1,147 @@ +import React, { useState, useRef, useCallback } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { useToast } from '@/components/ui/Toast'; +import { useCompanyImport } from '@/api/hooks'; +import { useTranslation } from 'react-i18next'; + +export interface CsvImportDialogProps { + open: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +interface ParsedRow { + [key: string]: string; +} + +function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } { + const lines = text.trim().split(/\n/); + if (lines.length === 0) return { headers: [], rows: [] }; + const headers = lines[0].split(',').map((h) => h.trim()); + const rows: ParsedRow[] = []; + for (let i = 1; i < lines.length; i++) { + if (!lines[i].trim()) continue; + const values = lines[i].split(',').map((v) => v.trim()); + const row: ParsedRow = {}; + headers.forEach((header, idx) => { + row[header] = values[idx] || ''; + }); + rows.push(row); + } + return { headers, rows }; +} + +export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) { + const { t } = useTranslation(); + const toast = useToast(); + const importMutation = useCompanyImport(); + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null); + const [error, setError] = useState(null); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.name.endsWith('.csv')) { + setError('Bitte wählen Sie eine CSV-Datei aus.'); + return; + } + setError(null); + setSelectedFile(file); + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result as string; + const parsed = parseCSV(text); + setPreviewData(parsed); + }; + reader.readAsText(file); + }, []); + + const handleImport = async () => { + if (!selectedFile) return; + try { + await importMutation.mutateAsync(selectedFile); + toast.success('Import erfolgreich abgeschlossen.'); + setSelectedFile(null); + setPreviewData(null); + setError(null); + onSuccess?.(); + onClose(); + } catch (err: any) { + toast.error(err.message || 'Import fehlgeschlagen.'); + } + }; + + const handleClose = () => { + setSelectedFile(null); + setPreviewData(null); + setError(null); + onClose(); + }; + + return ( + +
+
+

+ Wählen Sie eine CSV-Datei mit Firmendaten. Erforderliche Spalte: name. + Optionale Spalten: account_number, industry, phone, email, website, description. +

+ + {error &&

{error}

} +
+ + {previewData && previewData.rows.length > 0 && ( +
+

Vorschau ({previewData.rows.length} Datensätze)

+
+ + + + {previewData.headers.map((header) => ( + + ))} + + + + {previewData.rows.slice(0, 10).map((row, idx) => ( + + {previewData.headers.map((header) => ( + + ))} + + ))} + +
{header}
{row[header]}
+
+ {previewData.rows.length > 10 && ( +

Zeige 10 von {previewData.rows.length} Datensätzen.

+ )} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/shared/DataGrid.tsx b/frontend/src/components/shared/DataGrid.tsx new file mode 100644 index 0000000..63175e8 --- /dev/null +++ b/frontend/src/components/shared/DataGrid.tsx @@ -0,0 +1,160 @@ +import React, { useState, useMemo } from 'react'; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + flexRender, + ColumnDef, + SortingState, + ColumnFiltersState, +} from '@tanstack/react-table'; +import clsx from 'clsx'; +import { Pagination } from '@/components/ui/Pagination'; +import { useTranslation } from 'react-i18next'; + +export interface DataGridProps { + columns: ColumnDef[]; + data: T[]; + total: number; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onRowClick?: (row: T) => void; + emptyMessage?: string; + loading?: boolean; + enableSorting?: boolean; + enableGlobalFilter?: boolean; + globalFilter?: string; + onGlobalFilterChange?: (value: string) => void; + testId?: string; +} + +export function DataGrid>({ + columns, + data, + total, + page, + pageSize, + onPageChange, + onRowClick, + emptyMessage, + loading = false, + enableSorting = true, + testId, +}: DataGridProps) { + const { t } = useTranslation(); + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, + getFilteredRowModel: getFilteredRowModel(), + }); + + const totalPages = Math.ceil(total / pageSize); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + const sortDir = header.column.getIsSorted(); + return ( + + ); + })} + + ))} + + + {loading ? ( + + + + ) : table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original) : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={onRowClick ? (e) => { + if (e.key === 'Enter') onRowClick(row.original); + } : undefined} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
{ + if (canSort && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + header.column.toggleSorting(); + } + }} + tabIndex={canSort ? 0 : undefined} + role={canSort ? 'button' : undefined} + aria-label={canSort ? `${flexRender(header.column.columnDef.header, header.getContext())}, sortierbar` : undefined} + > +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {canSort && ( + + )} +
+
+ + + {t('common.loading')} + +
+ {emptyMessage || t('table.empty')} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/shared/SearchDropdown.tsx b/frontend/src/components/shared/SearchDropdown.tsx new file mode 100644 index 0000000..cdb5142 --- /dev/null +++ b/frontend/src/components/shared/SearchDropdown.tsx @@ -0,0 +1,158 @@ +import React, { useState, useRef, useEffect } from 'react'; +import clsx from 'clsx'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useGlobalSearch, SearchResult } from '@/api/hooks'; + +export interface SearchDropdownProps { + placeholder?: string; +} + +function highlightMatch(text: string, query: string): React.ReactNode { + if (!query.trim()) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} + +export function SearchDropdown({ placeholder }: SearchDropdownProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const { data: results, isLoading } = useGlobalSearch(debouncedQuery); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 300); + return () => clearTimeout(timer); + }, [query]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!results || results.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && activeIndex >= 0) { + e.preventDefault(); + const result = results[activeIndex]; + navigate(result.url); + setOpen(false); + setQuery(''); + } else if (e.key === 'Escape') { + setOpen(false); + inputRef.current?.blur(); + } + }; + + const handleResultClick = (result: SearchResult) => { + navigate(result.url); + setOpen(false); + setQuery(''); + }; + + const handleSeeAll = () => { + navigate(`/search?q=${encodeURIComponent(query)}`); + setOpen(false); + }; + + return ( +
+ { setQuery(e.target.value); setOpen(true); setActiveIndex(-1); }} + onFocus={() => query && setOpen(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder || t('topbar.search')} + className="w-64 pl-10 pr-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch" + aria-label={t('topbar.search')} + role="combobox" + aria-expanded={open} + aria-controls="search-results-list" + aria-autocomplete="list" + /> + + {open && query.trim() && ( +
+ {isLoading ? ( +
{t('common.loading')}
+ ) : results && results.length > 0 ? ( + <> +
    + {results.map((result, idx) => ( +
  • + +
  • + ))} +
+
+ +
+ + ) : ( +
{t('common.noResults')}
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/shared/StatCard.tsx b/frontend/src/components/shared/StatCard.tsx new file mode 100644 index 0000000..b8bc9e3 --- /dev/null +++ b/frontend/src/components/shared/StatCard.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Card } from '@/components/ui/Card'; +import { Badge, BadgeVariant } from '@/components/ui/Badge'; + +export interface StatCardProps { + label: string; + value: string | number; + change?: string; + changeVariant?: BadgeVariant; + icon?: React.ReactNode; + testId?: string; +} + +export function StatCard({ label, value, change, changeVariant = 'success', icon, testId }: StatCardProps) { + return ( + +
+
+

{label}

+

{value}

+ {change && ( +

+ {change} +

+ )} +
+ {icon && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/shared/Tabs.tsx b/frontend/src/components/shared/Tabs.tsx new file mode 100644 index 0000000..a6b1872 --- /dev/null +++ b/frontend/src/components/shared/Tabs.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import clsx from 'clsx'; + +export interface TabItem { + key: string; + label: string; + content: React.ReactNode; + badge?: number; +} + +export interface TabsProps { + tabs: TabItem[]; + defaultKey?: string; + className?: string; +} + +export function Tabs({ tabs, defaultKey, className }: TabsProps) { + const [activeKey, setActiveKey] = useState(defaultKey || tabs[0]?.key || ''); + const activeTab = tabs.find((t) => t.key === activeKey); + + return ( +
+
+
+ {tabs.map((tab) => ( + + ))} +
+
+
+ {activeTab?.content} +
+
+ ); +} diff --git a/frontend/src/components/shared/UnsavedChangesGuard.tsx b/frontend/src/components/shared/UnsavedChangesGuard.tsx new file mode 100644 index 0000000..0c81452 --- /dev/null +++ b/frontend/src/components/shared/UnsavedChangesGuard.tsx @@ -0,0 +1,28 @@ +import React, { useEffect, useRef } from 'react'; +import { useLocation, useNavigate, useBlocker } from 'react-router-dom'; + +export interface UnsavedChangesGuardProps { + isDirty: boolean; + message?: string; + onConfirm?: () => void; +} + +export function UnsavedChangesGuard({ isDirty, message = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', onConfirm }: UnsavedChangesGuardProps) { + const blocker = useBlocker(isDirty); + const messageRef = useRef(message); + messageRef.current = message; + + useEffect(() => { + if (blocker.state === 'blocked') { + const confirmed = window.confirm(messageRef.current); + if (confirmed) { + onConfirm?.(); + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker, onConfirm]); + + return null; +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 6b78648..9192b35 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -59,7 +59,87 @@ "warning": "Warnung", "info": "Information", "required": "Pflichtfeld", - "optional": "Optional" + "optional": "Optional", + "back": "Zurück", + "filter": "Filtern", + "reset": "Zurücksetzen", + "export": "Exportieren", + "import": "Importieren", + "name": "Name", + "email": "E-Mail", + "phone": "Telefon", + "status": "Status", + "role": "Rolle", + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "dashboard": { + "welcome": "Willkommen zurück", + "recentActivity": "Letzte Aktivitäten", + "statCompanies": "Firmen", + "statContacts": "Kontakte", + "statTasks": "Offene Aufgaben", + "statEmails": "E-Mails heute" + }, + "companies": { + "title": "Firmen", + "create": "Firma erstellen", + "edit": "Firma bearbeiten", + "name": "Firmenname", + "accountNumber": "Kontonummer", + "industry": "Branche", + "phone": "Telefon", + "email": "E-Mail", + "website": "Website", + "description": "Beschreibung", + "contacts": "Kontakte", + "files": "Dateien", + "activity": "Aktivität", + "overview": "Übersicht", + "export": "CSV exportieren", + "import": "CSV importieren", + "deleteConfirm": "Möchten Sie diese Firma wirklich löschen?", + "deleteConfirmTitle": "Firma löschen", + "emptyTitle": "Keine Firmen vorhanden", + "emptyDescription": "Erstellen Sie Ihre erste Firma, um loszulegen.", + "created": "Firma erfolgreich erstellt.", + "updated": "Firma erfolgreich aktualisiert.", + "deleted": "Firma erfolgreich gelöscht.", + "createFailed": "Firma konnte nicht erstellt werden.", + "updateFailed": "Firma konnte nicht aktualisiert werden.", + "notFound": "Firma nicht gefunden.", + "noContacts": "Diese Firma hat noch keine Kontakte.", + "noFiles": "Keine Dateien vorhanden.", + "noActivity": "Keine Aktivität vorhanden." + }, + "contacts": { + "title": "Kontakte", + "create": "Kontakt erstellen", + "edit": "Kontakt bearbeiten", + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail", + "phone": "Telefon", + "position": "Position", + "companies": "Firmen", + "files": "Dateien", + "activity": "Aktivität", + "overview": "Übersicht", + "deleteConfirm": "Möchten Sie diesen Kontakt wirklich löschen?", + "deleteConfirmTitle": "Kontakt löschen", + "emptyTitle": "Keine Kontakte vorhanden", + "emptyDescription": "Erstellen Sie Ihren ersten Kontakt, um loszulegen.", + "created": "Kontakt erfolgreich erstellt.", + "updated": "Kontakt erfolgreich aktualisiert.", + "deleted": "Kontakt erfolgreich gelöscht.", + "createFailed": "Kontakt konnte nicht erstellt werden.", + "updateFailed": "Kontakt konnte nicht aktualisiert werden.", + "notFound": "Kontakt nicht gefunden.", + "assignCompanies": "Firmen zuweisen", + "noCompanies": "Dieser Kontakt ist keiner Firma zugeordnet.", + "noFiles": "Keine Dateien vorhanden.", + "noActivity": "Keine Aktivität vorhanden.", + "fullName": "Name" }, "settings": { "title": "Einstellungen", @@ -70,7 +150,63 @@ "system": "System", "profile": "Profil", "notifications": "Benachrichtigungen", - "security": "Sicherheit" + "security": "Sicherheit", + "roles": "Rollen", + "users": "Benutzer", + "system": "System", + "name": "Name", + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail", + "avatar": "Profilbild", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "confirmPassword": "Passwort bestätigen", + "changePassword": "Passwort ändern", + "profileSaved": "Profil erfolgreich gespeichert.", + "passwordChanged": "Passwort erfolgreich geändert.", + "roleName": "Rollenname", + "permissions": "Berechtigungen", + "createRole": "Rolle erstellen", + "roleCreated": "Rolle erfolgreich erstellt.", + "roleUpdated": "Rolle erfolgreich aktualisiert.", + "noRoles": "Keine Rollen vorhanden.", + "inviteUser": "Benutzer einladen", + "deactivate": "Deaktivieren", + "activate": "Aktivieren", + "userInvited": "Benutzer erfolgreich eingeladen.", + "userDeactivated": "Benutzer deaktiviert.", + "userActivated": "Benutzer aktiviert.", + "noUsers": "Keine Benutzer vorhanden.", + "assignRole": "Rolle zuweisen", + "inviteEmail": "E-Mail-Adresse", + "inviteRole": "Rolle" + }, + "auditLog": { + "title": "Audit-Log", + "user": "Benutzer", + "action": "Aktion", + "entity": "Entität", + "dateFrom": "Datum von", + "dateTo": "Datum bis", + "details": "Details", + "timestamp": "Zeitpunkt", + "empty": "Keine Audit-Log-Einträge vorhanden.", + "notAvailable": "Audit-Log ist aktuell nicht verfügbar.", + "entityId": "Entität-ID" + }, + "search": { + "title": "Suchergebnisse", + "filters": "Filter", + "entityType": "Entitätstyp", + "allTypes": "Alle Typen", + "companies": "Firmen", + "contacts": "Kontakte", + "noResults": "Keine Ergebnisse für \"{{query}}\" gefunden.", + "resultsFor": "Ergebnisse für \"{{query}}\"", + "dateFrom": "Datum von", + "dateTo": "Datum bis", + "enterQuery": "Geben Sie einen Suchbegriff ein." }, "topbar": { "tenant": "Mandant", @@ -120,6 +256,7 @@ "required": "Dieses Feld ist erforderlich.", "email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.", "minLength": "Mindestens {{count}} Zeichen erforderlich.", - "maxLength": "Maximal {{count}} Zeichen erlaubt." + "maxLength": "Maximal {{count}} Zeichen erlaubt.", + "passwordMismatch": "Passwörter stimmen nicht überein." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c58e7ea..95b648f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -59,7 +59,87 @@ "warning": "Warning", "info": "Information", "required": "Required", - "optional": "Optional" + "optional": "Optional", + "back": "Back", + "filter": "Filter", + "reset": "Reset", + "export": "Export", + "import": "Import", + "name": "Name", + "email": "Email", + "phone": "Phone", + "status": "Status", + "role": "Role", + "active": "Active", + "inactive": "Inactive" + }, + "dashboard": { + "welcome": "Welcome back", + "recentActivity": "Recent Activity", + "statCompanies": "Companies", + "statContacts": "Contacts", + "statTasks": "Open Tasks", + "statEmails": "Emails Today" + }, + "companies": { + "title": "Companies", + "create": "Create Company", + "edit": "Edit Company", + "name": "Company Name", + "accountNumber": "Account Number", + "industry": "Industry", + "phone": "Phone", + "email": "Email", + "website": "Website", + "description": "Description", + "contacts": "Contacts", + "files": "Files", + "activity": "Activity", + "overview": "Overview", + "export": "Export CSV", + "import": "Import CSV", + "deleteConfirm": "Are you sure you want to delete this company?", + "deleteConfirmTitle": "Delete Company", + "emptyTitle": "No companies yet", + "emptyDescription": "Create your first company to get started.", + "created": "Company created successfully.", + "updated": "Company updated successfully.", + "deleted": "Company deleted successfully.", + "createFailed": "Failed to create company.", + "updateFailed": "Failed to update company.", + "notFound": "Company not found.", + "noContacts": "This company has no contacts yet.", + "noFiles": "No files available.", + "noActivity": "No activity available." + }, + "contacts": { + "title": "Contacts", + "create": "Create Contact", + "edit": "Edit Contact", + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email", + "phone": "Phone", + "position": "Position", + "companies": "Companies", + "files": "Files", + "activity": "Activity", + "overview": "Overview", + "deleteConfirm": "Are you sure you want to delete this contact?", + "deleteConfirmTitle": "Delete Contact", + "emptyTitle": "No contacts yet", + "emptyDescription": "Create your first contact to get started.", + "created": "Contact created successfully.", + "updated": "Contact updated successfully.", + "deleted": "Contact deleted successfully.", + "createFailed": "Failed to create contact.", + "updateFailed": "Failed to update contact.", + "notFound": "Contact not found.", + "assignCompanies": "Assign Companies", + "noCompanies": "This contact is not assigned to any company.", + "noFiles": "No files available.", + "noActivity": "No activity available.", + "fullName": "Name" }, "settings": { "title": "Settings", @@ -70,7 +150,63 @@ "system": "System", "profile": "Profile", "notifications": "Notifications", - "security": "Security" + "security": "Security", + "roles": "Roles", + "users": "Users", + "system": "System", + "name": "Name", + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email", + "avatar": "Avatar", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmPassword": "Confirm Password", + "changePassword": "Change Password", + "profileSaved": "Profile saved successfully.", + "passwordChanged": "Password changed successfully.", + "roleName": "Role Name", + "permissions": "Permissions", + "createRole": "Create Role", + "roleCreated": "Role created successfully.", + "roleUpdated": "Role updated successfully.", + "noRoles": "No roles available.", + "inviteUser": "Invite User", + "deactivate": "Deactivate", + "activate": "Activate", + "userInvited": "User invited successfully.", + "userDeactivated": "User deactivated.", + "userActivated": "User activated.", + "noUsers": "No users available.", + "assignRole": "Assign Role", + "inviteEmail": "Email Address", + "inviteRole": "Role" + }, + "auditLog": { + "title": "Audit Log", + "user": "User", + "action": "Action", + "entity": "Entity", + "dateFrom": "Date From", + "dateTo": "Date To", + "details": "Details", + "timestamp": "Timestamp", + "empty": "No audit log entries available.", + "notAvailable": "Audit log is currently not available.", + "entityId": "Entity ID" + }, + "search": { + "title": "Search Results", + "filters": "Filters", + "entityType": "Entity Type", + "allTypes": "All Types", + "companies": "Companies", + "contacts": "Contacts", + "noResults": "No results found for \"{{query}}\".", + "resultsFor": "Results for \"{{query}}\"", + "dateFrom": "Date From", + "dateTo": "Date To", + "enterQuery": "Enter a search term." }, "topbar": { "tenant": "Tenant", @@ -120,6 +256,7 @@ "required": "This field is required.", "email": "Please enter a valid email address.", "minLength": "At least {{count}} characters required.", - "maxLength": "At most {{count}} characters allowed." + "maxLength": "At most {{count}} characters allowed.", + "passwordMismatch": "Passwords do not match." } } diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 0000000..c62eb4c --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,167 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ColumnDef } from '@tanstack/react-table'; +import { useAuditLog, AuditLogEntry } from '@/api/hooks'; +import { DataGrid } from '@/components/shared/DataGrid'; +import { Card } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Badge } from '@/components/ui/Badge'; + +export function AuditLogPage() { + const { t } = useTranslation(); + + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [filterUser, setFilterUser] = useState(''); + const [filterAction, setFilterAction] = useState(''); + const [filterEntity, setFilterEntity] = useState(''); + const [filterDateFrom, setFilterDateFrom] = useState(''); + const [filterDateTo, setFilterDateTo] = useState(''); + + const filters = useMemo( + () => ({ + user: filterUser || undefined, + action: filterAction || undefined, + entity: filterEntity || undefined, + dateFrom: filterDateFrom || undefined, + dateTo: filterDateTo || undefined, + }), + [filterUser, filterAction, filterEntity, filterDateFrom, filterDateTo] + ); + + const { data, isLoading, isError } = useAuditLog(page, pageSize, filters); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'timestamp', + header: t('auditLog.timestamp'), + cell: (info) => { + const val = info.getValue(); + if (!val) return '—'; + const date = new Date(val); + return date.toLocaleString('de-DE'); + }, + }, + { + accessorKey: 'user', + header: t('auditLog.user'), + cell: (info) => info.getValue() || '—', + }, + { + accessorKey: 'action', + header: t('auditLog.action'), + cell: (info) => + info.getValue() ? {info.getValue()} : '—', + }, + { + accessorKey: 'entity', + header: t('auditLog.entity'), + cell: (info) => info.getValue() || '—', + }, + { + accessorKey: 'entity_id', + header: t('auditLog.entityId'), + cell: (info) => info.getValue() || '—', + }, + { + accessorKey: 'details', + header: t('auditLog.details'), + cell: (info) => { + const val = info.getValue(); + if (!val) return '—'; + return {val}; + }, + }, + ], + [t] + ); + + const handleResetFilters = () => { + setFilterUser(''); + setFilterAction(''); + setFilterEntity(''); + setFilterDateFrom(''); + setFilterDateTo(''); + setPage(1); + }; + + const hasFilters = filterUser || filterAction || filterEntity || filterDateFrom || filterDateTo; + + if (isError) { + return ( +
+

{t('auditLog.title')}

+ +
+ ); + } + + const entries = data?.items ?? []; + const total = data?.total ?? 0; + + return ( +
+

{t('auditLog.title')}

+ + +
+ setFilterUser(e.target.value)} + placeholder="anna.schmidt" + /> + setFilterAction(e.target.value)} + placeholder="create, update, delete" + /> + setFilterEntity(e.target.value)} + placeholder="company, contact" + /> + setFilterDateFrom(e.target.value)} + /> + setFilterDateTo(e.target.value)} + /> +
+ {hasFilters && ( +
+ +
+ )} +
+ + +
+ ); +} diff --git a/frontend/src/pages/CompaniesList.tsx b/frontend/src/pages/CompaniesList.tsx new file mode 100644 index 0000000..c677005 --- /dev/null +++ b/frontend/src/pages/CompaniesList.tsx @@ -0,0 +1,194 @@ +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ColumnDef } from '@tanstack/react-table'; +import { useCompanies, useCompanyExport, useDeleteCompany, Company } from '@/api/hooks'; +import { DataGrid } from '@/components/shared/DataGrid'; +import { CsvImportDialog } from '@/components/shared/CsvImportDialog'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { useToast } from '@/components/ui/Toast'; +import { Badge } from '@/components/ui/Badge'; + +export function CompaniesListPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const toast = useToast(); + + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [importOpen, setImportOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading } = useCompanies(page, pageSize, debouncedSearch); + const exportMutation = useCompanyExport(debouncedSearch); + const deleteMutation = useDeleteCompany(); + + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: t('companies.name'), + cell: (info) => ( + + {info.getValue()} + + ), + }, + { + accessorKey: 'account_number', + header: t('companies.accountNumber'), + cell: (info) => info.getValue() || '—', + }, + { + accessorKey: 'industry', + header: t('companies.industry'), + cell: (info) => + info.getValue() ? ( + {info.getValue()} + ) : ( + '—' + ), + }, + { + accessorKey: 'phone', + header: t('companies.phone'), + cell: (info) => info.getValue() || '—', + }, + { + accessorKey: 'email', + header: t('companies.email'), + cell: (info) => + info.getValue() ? ( + + {info.getValue()} + + ) : ( + '—' + ), + }, + ], + [t] + ); + + const handleExport = async () => { + try { + const blob = await exportMutation.mutateAsync(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `companies_${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast.success(t('companies.export') + ' — OK'); + } catch (err: any) { + toast.error(err.message || t('common.error')); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await deleteMutation.mutateAsync(deleteTarget.id); + toast.success(t('companies.deleted')); + setDeleteTarget(null); + } catch (err: any) { + toast.error(err.message || t('common.error')); + } + }; + + const companies = data?.items ?? []; + const total = data?.total ?? 0; + const isEmpty = !isLoading && companies.length === 0 && !debouncedSearch; + + if (isEmpty) { + return ( +
+
+

{t('companies.title')}

+
+ navigate('/companies/new')}> + {t('companies.create')} + + } + /> +
+ ); + } + + return ( +
+
+

{t('companies.title')}

+
+ + + +
+
+ +
+ setSearch(e.target.value)} + placeholder={t('common.search')} + aria-label={t('common.search')} + /> +
+ + navigate(`/companies/${row.id}`)} + loading={isLoading} + emptyMessage={t('common.noResults')} + testId="companies-grid" + /> + + setImportOpen(false)} + onSuccess={() => setPage(1)} + /> + + setDeleteTarget(null)} + /> +
+ ); +} diff --git a/frontend/src/pages/CompanyDetail.tsx b/frontend/src/pages/CompanyDetail.tsx new file mode 100644 index 0000000..88f3978 --- /dev/null +++ b/frontend/src/pages/CompanyDetail.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useCompany } from '@/api/hooks'; +import { Tabs, TabItem } from '@/components/shared/Tabs'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { Avatar } from '@/components/ui/Avatar'; + +export function CompanyDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { data: company, isLoading, isError } = useCompany(id); + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (isError || !company) { + return ( +
+ navigate('/companies')}>{t('common.back')}} + /> +
+ ); + } + + const contacts = company.contacts ?? []; + + const overviewTab: TabItem = { + key: 'overview', + label: t('companies.overview'), + content: ( +
+ +

{company.name}

+
+ +

{company.account_number || '—'}

+
+ + {company.industry ? {company.industry} :

} +
+ +

{company.phone || '—'}

+
+ + {company.email ? ( + {company.email} + ) :

} +
+ + {company.website ? ( + {company.website} + ) :

} +
+ {company.description && ( + +

{company.description}

+
+ )} +
+ ), + }; + + const contactsTab: TabItem = { + key: 'contacts', + label: t('companies.contacts'), + badge: contacts.length, + content: contacts.length === 0 ? ( + + ) : ( +
    + {contacts.map((contact) => ( +
  • + +
  • + ))} +
+ ), + }; + + const filesTab: TabItem = { + key: 'files', + label: t('companies.files'), + content: , + }; + + const activityTab: TabItem = { + key: 'activity', + label: t('companies.activity'), + content: , + }; + + return ( +
+
+
+ +

{company.name}

+
+ +
+ +
+ ); +} diff --git a/frontend/src/pages/CompanyForm.tsx b/frontend/src/pages/CompanyForm.tsx new file mode 100644 index 0000000..168937d --- /dev/null +++ b/frontend/src/pages/CompanyForm.tsx @@ -0,0 +1,189 @@ +import React, { useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useCompany, useCreateCompany, useUpdateCompany } from '@/api/hooks'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { useToast } from '@/components/ui/Toast'; +import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard'; + +const companySchema = z.object({ + name: z.string().min(1, 'Name ist erforderlich').max(100, 'Maximal 100 Zeichen'), + account_number: z.string().max(40, 'Maximal 40 Zeichen').optional().or(z.literal('')), + industry: z.string().max(50, 'Maximal 50 Zeichen').optional().or(z.literal('')), + phone: z.string().max(30, 'Maximal 30 Zeichen').optional().or(z.literal('')), + email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')), + website: z.string().max(500, 'Maximal 500 Zeichen').optional().or(z.literal('')), + description: z.string().optional().or(z.literal('')), +}); + +type CompanyFormValues = z.infer; + +export function CompanyFormPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const toast = useToast(); + const isEdit = !!id; + + const { data: company, isLoading } = useCompany(isEdit ? id : undefined); + const createMutation = useCreateCompany(); + const updateMutation = useUpdateCompany(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + resolver: zodResolver(companySchema), + defaultValues: { + name: '', + account_number: '', + industry: '', + phone: '', + email: '', + website: '', + description: '', + }, + }); + + useEffect(() => { + if (isEdit && company) { + reset({ + name: company.name || '', + account_number: company.account_number || '', + industry: company.industry || '', + phone: company.phone || '', + email: company.email || '', + website: company.website || '', + description: company.description || '', + }); + } + }, [company, isEdit, reset]); + + const onSubmit = async (values: CompanyFormValues) => { + try { + if (isEdit && id) { + await updateMutation.mutateAsync({ id, data: values }); + toast.success(t('companies.updated')); + navigate(`/companies/${id}`); + } else { + const result = await createMutation.mutateAsync(values) as { id: string }; + toast.success(t('companies.created')); + navigate(`/companies/${result.id}`); + } + } catch (err: any) { + toast.error(err.message || (isEdit ? t('companies.updateFailed') : t('companies.createFailed'))); + } + }; + + if (isEdit && isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+
+ +

+ {isEdit ? t('companies.edit') : t('companies.create')} +

+
+
+ +
+ + + + +
+ + + + + + + + + + + + +
+ + + + + + +