From 3f919340b54f98b7381d4665869d7a75ab72c19c Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sun, 15 Mar 2026 10:39:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Stammdaten,=20CRM=20Reporting,=20Hilfes?= =?UTF-8?q?ystem=20(hohe=20Priorit=C3=A4t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stammdaten (Kapitel 14): - 5 neue Prisma-Modelle: Department, Location, CostCenter, JobTitle, SkillCategory - MasterDataModule (Core Service): vollständiges CRUD + öffentliche Dropdown-Endpoints - Admin-UI /admin/master-data mit 5 Tabs, Inline-Edit, Farbwahl (Skill-Kategorien) CRM Reporting (Kapitel 22.9): - recharts ^2.12.0 installiert - Deals: GET /deals/stats (Win/Loss-Rate, Umsatz, Trend, Verlustgründe) - Aktivitäten: GET /activities/stats (nach Typ, Completion-Rate, offene Tasks) - Reports-Seite /crm/reports: LineChart, PieChart, BarChart mit Zeitraum-Filter Hilfesystem (Kapitel 16): - @anthropic-ai/sdk installiert; ANTHROPIC_API_KEY optional in .env - HelpModule (Core Service): POST /help/chat via Claude Haiku - HelpTooltip-Komponente: Hover-Tooltip für Formularfelder - HelpPanel: seitlicher Drawer mit Seitenkontext + KI-Chat - ❓-Button im Topbar (AppLayout), pageKey aus Route abgeleitet Migration erforderlich: prisma migrate deploy (core-service) Deployment: core rebuild, crm rebuild, frontend rebuild Co-Authored-By: Claude Sonnet 4.6 --- docs/Stand.md | 45 ++- packages/core-service/package-lock.json | 204 ++++++++++ packages/core-service/package.json | 1 + .../core-service/prisma/core.schema.prisma | 50 +++ packages/core-service/src/app.module.ts | 4 + .../core-service/src/config/env.validation.ts | 4 + .../src/core/help/help.controller.ts | 18 + .../core-service/src/core/help/help.module.ts | 9 + .../src/core/help/help.service.ts | 57 +++ .../master-data/master-data.controller.ts | 156 ++++++++ .../core/master-data/master-data.module.ts | 10 + .../core/master-data/master-data.service.ts | 117 ++++++ .../src/activities/activities.controller.ts | 10 + .../src/activities/activities.service.ts | 60 +++ .../crm-service/src/deals/deals.controller.ts | 11 + .../crm-service/src/deals/deals.service.ts | 80 ++++ .../src/deals/dto/stats-query.dto.ts | 15 + packages/frontend/package-lock.json | 375 +++++++++++++++++- packages/frontend/package.json | 3 +- packages/frontend/src/admin/AdminLayout.tsx | 1 + .../src/admin/AdminMasterDataPage.tsx | 245 ++++++++++++ .../src/components/HelpPanel/HelpPanel.tsx | 223 +++++++++++ .../src/components/HelpPanel/index.ts | 1 + .../components/HelpTooltip/HelpTooltip.tsx | 51 +++ .../src/components/HelpTooltip/index.ts | 1 + packages/frontend/src/crm/api.ts | 22 + packages/frontend/src/crm/hooks.ts | 21 + .../frontend/src/crm/reports/ReportsPage.tsx | 290 ++++++++++++++ packages/frontend/src/crm/types.ts | 42 ++ packages/frontend/src/shell/App.tsx | 4 + packages/frontend/src/shell/AppLayout.tsx | 54 +++ 31 files changed, 2178 insertions(+), 6 deletions(-) create mode 100644 packages/core-service/src/core/help/help.controller.ts create mode 100644 packages/core-service/src/core/help/help.module.ts create mode 100644 packages/core-service/src/core/help/help.service.ts create mode 100644 packages/core-service/src/core/master-data/master-data.controller.ts create mode 100644 packages/core-service/src/core/master-data/master-data.module.ts create mode 100644 packages/core-service/src/core/master-data/master-data.service.ts create mode 100644 packages/crm-service/src/deals/dto/stats-query.dto.ts create mode 100644 packages/frontend/src/admin/AdminMasterDataPage.tsx create mode 100644 packages/frontend/src/components/HelpPanel/HelpPanel.tsx create mode 100644 packages/frontend/src/components/HelpPanel/index.ts create mode 100644 packages/frontend/src/components/HelpTooltip/HelpTooltip.tsx create mode 100644 packages/frontend/src/components/HelpTooltip/index.ts create mode 100644 packages/frontend/src/crm/reports/ReportsPage.tsx diff --git a/docs/Stand.md b/docs/Stand.md index 72063c2..0fcfd7f 100644 --- a/docs/Stand.md +++ b/docs/Stand.md @@ -1,5 +1,5 @@ # INSIGHT MVP — Aktueller Implementierungsstand -*Stand: 2026-03-15* +*Stand: 2026-03-15 (Update)* --- @@ -87,6 +87,27 @@ - ✅ SSL/Domain (`/admin/ssl`) - ✅ Profilzugriff (`/admin/profile-access`) - ✅ CRM Sichtbarkeit (`/admin/crm-settings`) +- ✅ **Stammdaten (`/admin/master-data`)**: + - Abteilungen, Standorte, Kostenstellen, Stellenbezeichnungen, Skill-Kategorien + - Inline-Edit, Sortierung, Farbwahl (Skill-Kategorien) + - Prisma-Modelle: `departments`, `locations`, `cost_centers`, `job_titles`, `skill_categories` + - Öffentliche Dropdown-Endpoints (`/master-data/public/...`) für Frontend-Formulare + +### CRM Reporting +- ✅ **Reports-Seite (`/crm/reports`)** mit recharts: + - Deals-Tab: Win/Loss-Rate, Gesamtumsatz, Ø Deal-Wert, Linechart (12-Monats-Trend), Pie (Verlustgründe), Bar (Pipeline-Stages) + - Aktivitäten-Tab: Kennzahlen-Karten, Balkendiagramm nach Typ (Gesamt vs. Abgeschlossen) + - Zeitraum-Filter: Dieser Monat / Dieses Quartal / Dieses Jahr + - Neue Backend-Endpoints: `GET /deals/stats`, `GET /activities/stats` + +### Hilfesystem +- ✅ **HelpTooltip-Komponente**: Hover-Tooltip mit `❓`-Icon für Formularfelder +- ✅ **HelpPanel-Komponente**: Seitlicher Drawer mit kontextuellem Hilfetext + KI-Assistent + - Seiten-spezifische Hilfetexte (13+ Seiten definiert) + - KI-Chat via Claude Haiku (Anthropic API, `ANTHROPIC_API_KEY` in `.env`) + - Graceful Degradation: statischer Hilfetext funktioniert ohne API-Key +- ✅ **❓-Button im Topbar** (öffnet HelpPanel mit aktuellem Seitenkontext) +- ✅ Backend-Endpoint `POST /help/chat` (Core Service, JWT-geschützt) ### Login-Screen-Branding - ✅ Dynamischer Hintergrund aus Branding-Einstellungen @@ -111,12 +132,16 @@ | Container | Docker Compose | ### Prisma-Schemas -- `core.schema.prisma` — User, Auth, Profile, Tenant, Integrations, ProfileAccess +- `core.schema.prisma` — User, Auth, Profile, Tenant, Integrations, ProfileAccess, **Stammdaten** (Department, Location, CostCenter, JobTitle, SkillCategory) - `crm.schema.prisma` — CRM-Entities, Pipelines, CustomFields, Contracts, Visibility +### Neue Abhängigkeiten +- Frontend: `recharts ^2.12.0` (Charts für Reports-Seite) +- Core Service: `@anthropic-ai/sdk ^0.37.0` (KI-Hilfe-Chat) + ### Branching - Aktiver Branch: `feature/crm-service` -- ~205 Commits seit Initial +- ~208 Commits seit Initial --- @@ -156,12 +181,26 @@ GET /api/v1/settings/branding POST /api/v1/settings/branding GET /api/v1/settings/company POST /api/v1/settings/company + +GET /api/v1/master-data/departments (Admin) +POST /api/v1/master-data/departments +PATCH /api/v1/master-data/departments/:id +DELETE /api/v1/master-data/departments/:id +... (locations, cost-centers, job-titles, skill-categories — jeweils CRUD) +GET /api/v1/master-data/public/departments (@Public) +GET /api/v1/master-data/public/locations (@Public) +GET /api/v1/master-data/public/job-titles (@Public) +GET /api/v1/master-data/public/skill-categories (@Public) + +POST /api/v1/help/chat ``` ## API-Endpunkte (CRM Service) ``` Companies, Contacts, Deals, Activities, Pipelines, ... (vollständiges CRUD) +GET /api/v1/crm/deals/stats?period=YEAR +GET /api/v1/crm/activities/stats?period=YEAR GET /api/v1/crm/visibility-settings PUT /api/v1/crm/visibility-settings/:entity GET /api/v1/crm/office365/emails diff --git a/packages/core-service/package-lock.json b/packages/core-service/package-lock.json index 2347a06..4694ce9 100644 --- a/packages/core-service/package-lock.json +++ b/packages/core-service/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@anthropic-ai/sdk": "^0.37.0", "@azure/msal-node": "^5.0.6", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", @@ -229,6 +230,36 @@ "tslib": "^2.1.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz", + "integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@azure/msal-common": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz", @@ -3200,6 +3231,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -3749,6 +3790,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3824,6 +3877,18 @@ "node": ">= 6.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -4002,6 +4067,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4847,6 +4918,18 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5182,6 +5265,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -5480,6 +5572,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5836,6 +5943,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6326,6 +6442,41 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6743,6 +6894,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -6830,6 +6996,15 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8755,6 +8930,26 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -11521,6 +11716,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/packages/core-service/package.json b/packages/core-service/package.json index 00d7543..a63801b 100644 --- a/packages/core-service/package.json +++ b/packages/core-service/package.json @@ -26,6 +26,7 @@ "prisma:seed": "ts-node prisma/seed.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.37.0", "@azure/msal-node": "^5.0.6", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 7a18d0c..a5a8846 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -390,3 +390,53 @@ model ExpertAttachment { @@map("expert_attachments") } + +// -------------------------------------------------------- +// Stammdaten - Verwaltete Referenzlisten +// -------------------------------------------------------- +model Department { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + @@map("departments") +} + +model Location { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + @@map("locations") +} + +model CostCenter { + id String @id @default(uuid()) @db.Uuid + code String @db.VarChar(50) + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + @@map("cost_centers") +} + +model JobTitle { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + @@map("job_titles") +} + +model SkillCategory { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(100) + color String? @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + @@map("skill_categories") +} diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts index 6ff0c1d..c12aa03 100644 --- a/packages/core-service/src/app.module.ts +++ b/packages/core-service/src/app.module.ts @@ -13,6 +13,8 @@ import { ExpertProfileModule } from './core/expert-profile/expert-profile.module import { SettingsModule } from './core/settings/settings.module'; import { IntegrationsModule } from './core/integrations/integrations.module'; import { ProfileAccessModule } from './core/profile-access/profile-access.module'; +import { MasterDataModule } from './core/master-data/master-data.module'; +import { HelpModule } from './core/help/help.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { validateConfig } from './config/env.validation'; @@ -48,6 +50,8 @@ import { validateConfig } from './config/env.validation'; SettingsModule, IntegrationsModule, ProfileAccessModule, + MasterDataModule, + HelpModule, ], providers: [ // Global Guards: Alle Routen sind standardmaessig geschuetzt diff --git a/packages/core-service/src/config/env.validation.ts b/packages/core-service/src/config/env.validation.ts index e5b99c2..4e46925 100644 --- a/packages/core-service/src/config/env.validation.ts +++ b/packages/core-service/src/config/env.validation.ts @@ -112,6 +112,10 @@ class EnvironmentVariables { @IsOptional() @IsString() INTEGRATION_ENCRYPTION_KEY?: string; + + @IsOptional() + @IsString() + ANTHROPIC_API_KEY?: string; } export function validateConfig( diff --git a/packages/core-service/src/core/help/help.controller.ts b/packages/core-service/src/core/help/help.controller.ts new file mode 100644 index 0000000..454c2be --- /dev/null +++ b/packages/core-service/src/core/help/help.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { HelpService, ChatMessage } from './help.service'; + +@ApiTags('Help') +@Controller('help') +export class HelpController { + constructor(private readonly svc: HelpService) {} + + @Post('chat') + @HttpCode(200) + @ApiOperation({ summary: 'KI-Hilfe-Chat (Claude)' }) + chat( + @Body() body: { messages: ChatMessage[]; context?: string }, + ): Promise<{ reply: string }> { + return this.svc.chat(body.messages ?? [], body.context); + } +} diff --git a/packages/core-service/src/core/help/help.module.ts b/packages/core-service/src/core/help/help.module.ts new file mode 100644 index 0000000..3358d4b --- /dev/null +++ b/packages/core-service/src/core/help/help.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HelpController } from './help.controller'; +import { HelpService } from './help.service'; + +@Module({ + controllers: [HelpController], + providers: [HelpService], +}) +export class HelpModule {} diff --git a/packages/core-service/src/core/help/help.service.ts b/packages/core-service/src/core/help/help.service.ts new file mode 100644 index 0000000..d8e5ba5 --- /dev/null +++ b/packages/core-service/src/core/help/help.service.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Anthropic from '@anthropic-ai/sdk'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; +} + +@Injectable() +export class HelpService { + private readonly logger = new Logger(HelpService.name); + private client: Anthropic | null = null; + + constructor(private readonly config: ConfigService) { + const apiKey = this.config.get('ANTHROPIC_API_KEY'); + if (apiKey) { + this.client = new Anthropic({ apiKey }); + this.logger.log('KI-Hilfe-Chat aktiviert'); + } else { + this.logger.warn('ANTHROPIC_API_KEY nicht konfiguriert — KI-Chat deaktiviert'); + } + } + + async chat(messages: ChatMessage[], context?: string): Promise<{ reply: string }> { + if (!this.client) { + throw new ServiceUnavailableException('KI-Chat nicht konfiguriert. Bitte ANTHROPIC_API_KEY setzen.'); + } + + const systemPrompt = [ + 'Du bist ein freundlicher Hilfsassistent für INSIGHT, eine Business-Plattform für Mittelstandsunternehmen.', + 'INSIGHT enthält folgende Bereiche: CRM (Unternehmen, Kontakte, Deals, Aktivitäten, Kanban, Reports), Expertenprofil (Skills, Projekte, Zertifizierungen), Dashboard (E-Mail, Kalender, Aufgaben via Microsoft 365), Admin-Bereich (Benutzerverwaltung, Branding, Firmendaten, Stammdaten).', + 'Beantworte Fragen zur Bedienung der Plattform kompakt und hilfsbereit auf Deutsch.', + 'Halte Antworten kurz (max. 3-4 Sätze). Wenn du etwas nicht weißt, sag das ehrlich.', + context ? `Aktueller Kontext: ${context}` : '', + ].filter(Boolean).join(' '); + + try { + const response = await this.client.messages.create({ + model: 'claude-haiku-4-5', + max_tokens: 1024, + system: systemPrompt, + messages: messages.slice(-10).map(m => ({ role: m.role, content: m.content })), + }); + + const reply = response.content + .filter(b => b.type === 'text') + .map(b => (b as { type: 'text'; text: string }).text) + .join(''); + + return { reply }; + } catch (err) { + this.logger.error('Claude API Fehler:', err); + throw new ServiceUnavailableException('KI-Chat vorübergehend nicht verfügbar.'); + } + } +} diff --git a/packages/core-service/src/core/master-data/master-data.controller.ts b/packages/core-service/src/core/master-data/master-data.controller.ts new file mode 100644 index 0000000..30078db --- /dev/null +++ b/packages/core-service/src/core/master-data/master-data.controller.ts @@ -0,0 +1,156 @@ +import { + Controller, Get, Post, Patch, Delete, + Body, Param, UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Public } from '../../common/decorators/public.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { + MasterDataService, + CreateDepartmentDto, + CreateLocationDto, + CreateCostCenterDto, + CreateJobTitleDto, + CreateSkillCategoryDto, +} from './master-data.service'; + +@ApiTags('Master Data') +@Controller('master-data') +export class MasterDataController { + constructor(private readonly svc: MasterDataService) {} + + // ---- Public endpoints (für Dropdowns) ---- + + @Get('public/departments') + @Public() + @ApiOperation({ summary: 'Abteilungen (öffentlich)' }) + getPublicDepartments() { return this.svc.getDepartments(); } + + @Get('public/locations') + @Public() + @ApiOperation({ summary: 'Standorte (öffentlich)' }) + getPublicLocations() { return this.svc.getLocations(); } + + @Get('public/job-titles') + @Public() + @ApiOperation({ summary: 'Stellenbezeichnungen (öffentlich)' }) + getPublicJobTitles() { return this.svc.getJobTitles(); } + + @Get('public/skill-categories') + @Public() + @ApiOperation({ summary: 'Skill-Kategorien (öffentlich)' }) + getPublicSkillCategories() { return this.svc.getSkillCategories(); } + + // ---- Admin endpoints ---- + + @Get('departments') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + getDepartments() { return this.svc.getDepartments(); } + + @Post('departments') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + createDepartment(@Body() dto: CreateDepartmentDto) { return this.svc.createDepartment(dto); } + + @Patch('departments/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + updateDepartment(@Param('id') id: string, @Body() dto: Partial & { sortOrder?: number }) { + return this.svc.updateDepartment(id, dto); + } + + @Delete('departments/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + deleteDepartment(@Param('id') id: string) { return this.svc.deleteDepartment(id); } + + @Get('locations') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + getLocations() { return this.svc.getLocations(); } + + @Post('locations') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + createLocation(@Body() dto: CreateLocationDto) { return this.svc.createLocation(dto); } + + @Patch('locations/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + updateLocation(@Param('id') id: string, @Body() dto: Partial & { sortOrder?: number }) { + return this.svc.updateLocation(id, dto); + } + + @Delete('locations/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + deleteLocation(@Param('id') id: string) { return this.svc.deleteLocation(id); } + + @Get('cost-centers') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + getCostCenters() { return this.svc.getCostCenters(); } + + @Post('cost-centers') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + createCostCenter(@Body() dto: CreateCostCenterDto) { return this.svc.createCostCenter(dto); } + + @Patch('cost-centers/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + updateCostCenter(@Param('id') id: string, @Body() dto: Partial & { sortOrder?: number }) { + return this.svc.updateCostCenter(id, dto); + } + + @Delete('cost-centers/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + deleteCostCenter(@Param('id') id: string) { return this.svc.deleteCostCenter(id); } + + @Get('job-titles') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + getJobTitles() { return this.svc.getJobTitles(); } + + @Post('job-titles') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + createJobTitle(@Body() dto: CreateJobTitleDto) { return this.svc.createJobTitle(dto); } + + @Patch('job-titles/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + updateJobTitle(@Param('id') id: string, @Body() dto: Partial & { sortOrder?: number }) { + return this.svc.updateJobTitle(id, dto); + } + + @Delete('job-titles/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + deleteJobTitle(@Param('id') id: string) { return this.svc.deleteJobTitle(id); } + + @Get('skill-categories') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + getSkillCategories() { return this.svc.getSkillCategories(); } + + @Post('skill-categories') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + createSkillCategory(@Body() dto: CreateSkillCategoryDto) { return this.svc.createSkillCategory(dto); } + + @Patch('skill-categories/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + updateSkillCategory(@Param('id') id: string, @Body() dto: Partial & { sortOrder?: number }) { + return this.svc.updateSkillCategory(id, dto); + } + + @Delete('skill-categories/:id') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + deleteSkillCategory(@Param('id') id: string) { return this.svc.deleteSkillCategory(id); } +} diff --git a/packages/core-service/src/core/master-data/master-data.module.ts b/packages/core-service/src/core/master-data/master-data.module.ts new file mode 100644 index 0000000..05d6f9d --- /dev/null +++ b/packages/core-service/src/core/master-data/master-data.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MasterDataController } from './master-data.controller'; +import { MasterDataService } from './master-data.service'; + +@Module({ + controllers: [MasterDataController], + providers: [MasterDataService], + exports: [MasterDataService], +}) +export class MasterDataModule {} diff --git a/packages/core-service/src/core/master-data/master-data.service.ts b/packages/core-service/src/core/master-data/master-data.service.ts new file mode 100644 index 0000000..127f744 --- /dev/null +++ b/packages/core-service/src/core/master-data/master-data.service.ts @@ -0,0 +1,117 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface CreateDepartmentDto { name: string; } +export interface CreateLocationDto { name: string; } +export interface CreateCostCenterDto { code: string; name: string; } +export interface CreateJobTitleDto { name: string; } +export interface CreateSkillCategoryDto { name: string; color?: string | null; } + +@Injectable() +export class MasterDataService { + constructor(private readonly prisma: PrismaService) {} + + // Departments + async getDepartments() { + return this.prisma.department.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }); + } + async createDepartment(dto: CreateDepartmentDto) { + return this.prisma.department.create({ data: { name: dto.name.trim() } }); + } + async updateDepartment(id: string, dto: Partial & { sortOrder?: number }) { + await this.findOrThrow('department', id); + return this.prisma.department.update({ where: { id }, data: { + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }}); + } + async deleteDepartment(id: string) { + await this.findOrThrow('department', id); + await this.prisma.department.delete({ where: { id } }); + } + + // Locations + async getLocations() { + return this.prisma.location.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }); + } + async createLocation(dto: CreateLocationDto) { + return this.prisma.location.create({ data: { name: dto.name.trim() } }); + } + async updateLocation(id: string, dto: Partial & { sortOrder?: number }) { + await this.findOrThrow('location', id); + return this.prisma.location.update({ where: { id }, data: { + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }}); + } + async deleteLocation(id: string) { + await this.findOrThrow('location', id); + await this.prisma.location.delete({ where: { id } }); + } + + // CostCenters + async getCostCenters() { + return this.prisma.costCenter.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }); + } + async createCostCenter(dto: CreateCostCenterDto) { + return this.prisma.costCenter.create({ data: { code: dto.code.trim(), name: dto.name.trim() } }); + } + async updateCostCenter(id: string, dto: Partial & { sortOrder?: number }) { + await this.findOrThrow('costCenter', id); + return this.prisma.costCenter.update({ where: { id }, data: { + ...(dto.code !== undefined && { code: dto.code.trim() }), + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }}); + } + async deleteCostCenter(id: string) { + await this.findOrThrow('costCenter', id); + await this.prisma.costCenter.delete({ where: { id } }); + } + + // JobTitles + async getJobTitles() { + return this.prisma.jobTitle.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }); + } + async createJobTitle(dto: CreateJobTitleDto) { + return this.prisma.jobTitle.create({ data: { name: dto.name.trim() } }); + } + async updateJobTitle(id: string, dto: Partial & { sortOrder?: number }) { + await this.findOrThrow('jobTitle', id); + return this.prisma.jobTitle.update({ where: { id }, data: { + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }}); + } + async deleteJobTitle(id: string) { + await this.findOrThrow('jobTitle', id); + await this.prisma.jobTitle.delete({ where: { id } }); + } + + // SkillCategories + async getSkillCategories() { + return this.prisma.skillCategory.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }); + } + async createSkillCategory(dto: CreateSkillCategoryDto) { + return this.prisma.skillCategory.create({ data: { name: dto.name.trim(), color: dto.color ?? null } }); + } + async updateSkillCategory(id: string, dto: Partial & { sortOrder?: number }) { + await this.findOrThrow('skillCategory', id); + return this.prisma.skillCategory.update({ where: { id }, data: { + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.color !== undefined && { color: dto.color ?? null }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }}); + } + async deleteSkillCategory(id: string) { + await this.findOrThrow('skillCategory', id); + await this.prisma.skillCategory.delete({ where: { id } }); + } + + private async findOrThrow(model: string, id: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const record = await (this.prisma as any)[model].findUnique({ where: { id } }); + if (!record) throw new NotFoundException(`${model} ${id} nicht gefunden`); + return record; + } +} diff --git a/packages/crm-service/src/activities/activities.controller.ts b/packages/crm-service/src/activities/activities.controller.ts index f6eade7..a2e480c 100644 --- a/packages/crm-service/src/activities/activities.controller.ts +++ b/packages/crm-service/src/activities/activities.controller.ts @@ -82,6 +82,16 @@ export class ActivitiesController { return { success: true, data: tasks, meta: { count: tasks.length } }; } + @Get('stats') + @ApiOperation({ summary: 'Aktivitäts-Statistiken / Reporting' }) + async getStats( + @CurrentUser() user: JwtPayload, + @Query('period') period: string, + ) { + const result = await this.activitiesService.getStats(user.tenantId!, period); + return singleResponse(result); + } + @Get(':id') @ApiOperation({ summary: 'Aktivitaet-Details abrufen' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) diff --git a/packages/crm-service/src/activities/activities.service.ts b/packages/crm-service/src/activities/activities.service.ts index 4e2a52a..7de0158 100644 --- a/packages/crm-service/src/activities/activities.service.ts +++ b/packages/crm-service/src/activities/activities.service.ts @@ -212,6 +212,66 @@ export class ActivitiesService { return this.prisma.activity.delete({ where: { id } }); } + // -------------------------------------------------------- + // Stats / Reporting + // -------------------------------------------------------- + + async getStats(tenantId: string, period: string = 'YEAR') { + const now = new Date(); + let daysBack: number; + if (period === 'MONTH') daysBack = 30; + else if (period === 'QUARTER') daysBack = 90; + else daysBack = 365; + + const start = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000); + + const activities = await this.prisma.activity.findMany({ + where: { tenantId, createdAt: { gte: start } }, + select: { type: true, completedAt: true, scheduledAt: true }, + }); + + // Count by type + const typeMap = new Map(); + for (const a of activities) { + const t = a.type; + if (!typeMap.has(t)) typeMap.set(t, { total: 0, completed: 0 }); + const entry = typeMap.get(t)!; + entry.total += 1; + if (a.completedAt !== null) entry.completed += 1; + } + + const byType = Array.from(typeMap.entries()).map(([type, counts]) => ({ + type, + total: counts.total, + completed: counts.completed, + completionRate: counts.total > 0 ? Math.round((counts.completed / counts.total) * 1000) / 10 : 0, + })); + + const totalActivities = activities.length; + const totalCompleted = activities.filter(a => a.completedAt !== null).length; + const overallCompletionRate = totalActivities > 0 ? Math.round((totalCompleted / totalActivities) * 1000) / 10 : 0; + + // Upcoming tasks (TASK/FOLLOWUP, not completed, scheduledAt within 7 days) + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const upcomingTasks = await this.prisma.activity.count({ + where: { + tenantId, + type: { in: ['TASK', 'FOLLOWUP'] }, + completedAt: null, + scheduledAt: { gte: now, lte: sevenDaysFromNow }, + }, + }); + + return { + period, + totalActivities, + totalCompleted, + overallCompletionRate, + byType, + upcomingTasks, + }; + } + /** Alle offenen Aufgaben (TASK + FOLLOWUP, nicht erledigt) */ async findOpenTasks(tenantId: string) { return this.prisma.activity.findMany({ diff --git a/packages/crm-service/src/deals/deals.controller.ts b/packages/crm-service/src/deals/deals.controller.ts index 62044eb..7627e2e 100644 --- a/packages/crm-service/src/deals/deals.controller.ts +++ b/packages/crm-service/src/deals/deals.controller.ts @@ -25,6 +25,7 @@ import { CreateDealDto } from './dto/create-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto'; import { QueryDealsDto } from './dto/query-deals.dto'; import { ForecastQueryDto } from './dto/forecast-query.dto'; +import { StatsQueryDto } from './dto/stats-query.dto'; import { AddOwnerDto } from '../common/dto/owner.dto'; import { OwnersService } from '../owners/owners.service'; import { CurrentUser, JwtPayload } from '../common/decorators'; @@ -86,6 +87,16 @@ export class DealsController { return singleResponse(result); } + @Get('stats') + @ApiOperation({ summary: 'Deal-Statistiken / Reporting' }) + async getStats( + @CurrentUser() user: JwtPayload, + @Query() query: StatsQueryDto, + ) { + const result = await this.dealsService.getStats(user.tenantId!, query.period); + return singleResponse(result); + } + @Get(':id') @ApiOperation({ summary: 'Vorgangsdetails abrufen' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index 0aefed3..9d45ee4 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -516,6 +516,86 @@ export class DealsService { }; } + // -------------------------------------------------------- + // Stats / Reporting + // -------------------------------------------------------- + + async getStats(tenantId: string, period: 'MONTH' | 'QUARTER' | 'YEAR' = 'YEAR') { + const now = new Date(); + let daysBack: number; + if (period === 'MONTH') daysBack = 30; + else if (period === 'QUARTER') daysBack = 90; + else daysBack = 365; + + const start = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000); + + const [wonDeals, lostDeals, openDeals] = await Promise.all([ + this.prisma.deal.findMany({ + where: { tenantId, status: 'WON', closedAt: { gte: start } }, + select: { id: true, value: true, lostReason: true }, + }), + this.prisma.deal.findMany({ + where: { tenantId, status: 'LOST', closedAt: { gte: start } }, + select: { id: true, value: true, lostReason: true }, + }), + this.prisma.deal.count({ where: { tenantId, status: 'OPEN' } }), + ]); + + const totalWon = wonDeals.length; + const totalLost = lostDeals.length; + const totalClosed = totalWon + totalLost; + const winRate = totalClosed > 0 ? (totalWon / totalClosed) * 100 : 0; + + const totalRevenue = wonDeals.reduce((sum, d) => sum + Number(d.value ?? 0), 0); + const avgDealValue = totalWon > 0 ? totalRevenue / totalWon : 0; + + // Lost by reason + const reasonMap = new Map(); + for (const d of lostDeals) { + const key = d.lostReason ?? 'Unbekannt'; + reasonMap.set(key, (reasonMap.get(key) ?? 0) + 1); + } + const lostByReason = Array.from(reasonMap.entries()).map(([reason, count]) => ({ reason, count })); + + // Monthly trend (last 12 months) + const monthlyTrend: { month: string; won: number; lost: number; revenue: number }[] = []; + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthStart = new Date(d.getFullYear(), d.getMonth(), 1); + const monthEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); + const label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + + const [mWon, mLost] = await Promise.all([ + this.prisma.deal.findMany({ + where: { tenantId, status: 'WON', closedAt: { gte: monthStart, lte: monthEnd } }, + select: { value: true }, + }), + this.prisma.deal.count({ + where: { tenantId, status: 'LOST', closedAt: { gte: monthStart, lte: monthEnd } }, + }), + ]); + + monthlyTrend.push({ + month: label, + won: mWon.length, + lost: mLost, + revenue: mWon.reduce((sum, d) => sum + Number(d.value ?? 0), 0), + }); + } + + return { + period, + totalWon, + totalLost, + openDeals, + winRate: Math.round(winRate * 10) / 10, + totalRevenue, + avgDealValue: Math.round(avgDealValue * 100) / 100, + lostByReason, + monthlyTrend, + }; + } + private getPeriodBounds(period: ForecastPeriod): { start: Date; end: Date } { const now = new Date(); diff --git a/packages/crm-service/src/deals/dto/stats-query.dto.ts b/packages/crm-service/src/deals/dto/stats-query.dto.ts new file mode 100644 index 0000000..85f0b3d --- /dev/null +++ b/packages/crm-service/src/deals/dto/stats-query.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export enum StatsPeriod { + MONTH = 'MONTH', + QUARTER = 'QUARTER', + YEAR = 'YEAR', +} + +export class StatsQueryDto { + @ApiPropertyOptional({ enum: StatsPeriod, default: StatsPeriod.YEAR }) + @IsOptional() + @IsEnum(StatsPeriod) + period?: StatsPeriod; +} diff --git a/packages/frontend/package-lock.json b/packages/frontend/package-lock.json index 86f1f33..55da2d1 100644 --- a/packages/frontend/package-lock.json +++ b/packages/frontend/package-lock.json @@ -15,7 +15,8 @@ "axios": "^1.7.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^6.26.0" + "react-router-dom": "^6.26.0", + "recharts": "^2.12.0" }, "devDependencies": { "@types/react": "^18.3.0", @@ -285,6 +286,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1571,6 +1581,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2082,6 +2155,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2147,9 +2229,129 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2168,6 +2370,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2184,6 +2392,16 @@ "node": ">=0.4.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2560,6 +2778,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2567,6 +2791,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2881,6 +3114,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3017,6 +3259,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3132,6 +3380,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3275,6 +3532,23 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3318,6 +3592,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3360,6 +3640,69 @@ "react-dom": ">=16.8" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3496,6 +3839,12 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3601,6 +3950,28 @@ "punycode": "^2.1.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 8a77e69..fb676fa 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,7 +20,8 @@ "axios": "^1.7.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^6.26.0" + "react-router-dom": "^6.26.0", + "recharts": "^2.12.0" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index 9319660..e3e5108 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -11,6 +11,7 @@ const tabs = [ { to: '/admin/ssl', label: 'SSL / Domain' }, { to: '/admin/profile-access', label: 'Profilzugriff' }, { to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' }, + { to: '/admin/master-data', label: 'Stammdaten' }, ]; export function AdminLayout() { diff --git a/packages/frontend/src/admin/AdminMasterDataPage.tsx b/packages/frontend/src/admin/AdminMasterDataPage.tsx new file mode 100644 index 0000000..b41b4d4 --- /dev/null +++ b/packages/frontend/src/admin/AdminMasterDataPage.tsx @@ -0,0 +1,245 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; + +type TabKey = 'departments' | 'locations' | 'cost-centers' | 'job-titles' | 'skill-categories'; + +interface MasterItem { + id: string; + name: string; + code?: string; + color?: string | null; + sortOrder: number; +} + +const TABS: { key: TabKey; label: string }[] = [ + { key: 'departments', label: 'Abteilungen' }, + { key: 'locations', label: 'Standorte' }, + { key: 'cost-centers', label: 'Kostenstellen' }, + { key: 'job-titles', label: 'Stellenbezeichnungen' }, + { key: 'skill-categories', label: 'Skill-Kategorien' }, +]; + +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-sm)', + border: '1px solid var(--color-border)', + padding: '1.5rem', +}; + +const inputStyle: React.CSSProperties = { + padding: '0.4rem 0.6rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + background: 'var(--color-bg)', + color: 'var(--color-text)', +}; + +const btnStyle = (variant: 'primary' | 'danger' | 'ghost'): React.CSSProperties => ({ + padding: '0.35rem 0.75rem', + border: 'none', + borderRadius: 'var(--radius-sm)', + fontSize: '0.8125rem', + fontWeight: 600, + cursor: 'pointer', + background: variant === 'primary' ? 'var(--color-primary)' : variant === 'danger' ? 'var(--color-danger, #dc2626)' : 'transparent', + color: variant === 'ghost' ? 'var(--color-text-secondary)' : 'white', +}); + +function MasterDataTab({ tabKey }: { tabKey: TabKey }) { + const qc = useQueryClient(); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(''); + const [editCode, setEditCode] = useState(''); + const [editColor, setEditColor] = useState('#4f46e5'); + const [newName, setNewName] = useState(''); + const [newCode, setNewCode] = useState(''); + const [newColor, setNewColor] = useState('#4f46e5'); + const [adding, setAdding] = useState(false); + + const isCostCenter = tabKey === 'cost-centers'; + const isSkillCat = tabKey === 'skill-categories'; + + const { data: items = [], isLoading } = useQuery({ + queryKey: ['master-data', tabKey], + queryFn: () => api.get(`/master-data/${tabKey}`).then(r => r.data), + }); + + const invalidate = () => { void qc.invalidateQueries({ queryKey: ['master-data', tabKey] }); }; + + const createMut = useMutation({ + mutationFn: (data: Record) => api.post(`/master-data/${tabKey}`, data), + onSuccess: () => { invalidate(); setAdding(false); setNewName(''); setNewCode(''); setNewColor('#4f46e5'); }, + }); + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + api.patch(`/master-data/${tabKey}/${id}`, data), + onSuccess: () => { invalidate(); setEditId(null); }, + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => api.delete(`/master-data/${tabKey}/${id}`), + onSuccess: () => invalidate(), + }); + + const startEdit = (item: MasterItem) => { + setEditId(item.id); + setEditName(item.name); + setEditCode(item.code ?? ''); + setEditColor(item.color ?? '#4f46e5'); + }; + + const saveEdit = (id: string) => { + const data: Record = { name: editName }; + if (isCostCenter) data.code = editCode; + if (isSkillCat) data.color = editColor; + updateMut.mutate({ id, data }); + }; + + const handleCreate = () => { + if (!newName.trim()) return; + const data: Record = { name: newName }; + if (isCostCenter) data.code = newCode; + if (isSkillCat) data.color = newColor; + createMut.mutate(data); + }; + + if (isLoading) return

Lädt…

; + + return ( +
+ {/* Liste */} + {items.length === 0 && !adding && ( +

+ Noch keine Einträge vorhanden. +

+ )} + {items.map(item => ( +
+ {editId === item.id ? ( + <> + {isCostCenter && ( + setEditCode(e.target.value)} + placeholder="Code" + /> + )} + {isSkillCat && ( + setEditColor(e.target.value)} + style={{ width: 36, height: 28, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 2 }} /> + )} + setEditName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') saveEdit(item.id); if (e.key === 'Escape') setEditId(null); }} + autoFocus + /> + + + + ) : ( + <> + {isSkillCat && item.color && ( + + )} + {isCostCenter && item.code && ( + + {item.code} + + )} + {item.name} + + + + )} +
+ ))} + + {/* Neu-Formular */} + {adding ? ( +
+ {isCostCenter && ( + setNewCode(e.target.value)} placeholder="Code" autoFocus /> + )} + {isSkillCat && ( + setNewColor(e.target.value)} + style={{ width: 36, height: 28, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 2 }} /> + )} + setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setAdding(false); setNewName(''); } }} + placeholder="Name eingeben…" + autoFocus={!isCostCenter && !isSkillCat} + /> + + +
+ ) : ( + + )} + {createMut.isError && ( +

Fehler beim Erstellen

+ )} +
+ ); +} + +export function AdminMasterDataPage() { + const [activeTab, setActiveTab] = useState('departments'); + + return ( +
+
+

Stammdaten

+

+ Verwaltete Referenzlisten für Abteilungen, Standorte, Kostenstellen und mehr. +

+ + {/* Tabs */} +
+ {TABS.map(t => ( + + ))} +
+ + +
+
+ ); +} diff --git a/packages/frontend/src/components/HelpPanel/HelpPanel.tsx b/packages/frontend/src/components/HelpPanel/HelpPanel.tsx new file mode 100644 index 0000000..f3ab751 --- /dev/null +++ b/packages/frontend/src/components/HelpPanel/HelpPanel.tsx @@ -0,0 +1,223 @@ +import { useState, useRef, useEffect } from 'react'; +import api from '../../api/client'; + +interface ChatMessage { + role: 'user' | 'assistant'; + content: string; +} + +const HELP_TEXTS: Record = { + 'admin-company': { + title: 'Firmendaten', + text: 'Pflegen Sie hier Ihre Unternehmensangaben. Diese erscheinen als Fußzeile in jedem PDF-Export (Expertenprofil). Rechtliche Angaben wie Amtsgericht und Handelsregisternummer sind für das Impressum relevant.', + }, + 'admin-customize': { + title: 'Anpassungen', + text: 'Passen Sie das Erscheinungsbild von INSIGHT an: Logo, Farben, Sidebar-Breite und Login-Hintergrund. Änderungen werden sofort für alle Benutzer sichtbar.', + }, + 'admin-users': { + title: 'Benutzerverwaltung', + text: 'Verwalten Sie alle Benutzer der Plattform. Sie können neue Benutzer anlegen, Rollen zuweisen und Konten aktivieren oder deaktivieren.', + }, + 'admin-master-data': { + title: 'Stammdaten', + text: 'Verwalten Sie Referenzlisten wie Abteilungen, Standorte, Kostenstellen, Stellenbezeichnungen und Skill-Kategorien. Diese Listen werden in anderen Bereichen als Auswahloptionen verwendet.', + }, + 'crm-deals': { + title: 'Deals', + text: 'Verwalten Sie Ihre Verkaufschancen. Jeder Deal hat einen Wert, eine Stage in Ihrer Pipeline und einen Status (Offen, Gewonnen, Verloren). Nutzen Sie das Kanban-Board für eine visuelle Übersicht.', + }, + 'crm-reports': { + title: 'CRM Reports', + text: 'Analysieren Sie Ihre Vertriebsleistung: Win/Loss-Rate, Umsatz-Trend, Verlustgründe und Aktivitätsstatistiken. Wählen Sie den Zeitraum oben aus.', + }, + 'expert-profile': { + title: 'Expertenprofil', + text: 'Ihr persönliches Kompetenzprofil. Fügen Sie Skills, Projekterfahrungen, Zertifizierungen und Sprachen hinzu. Das Profil kann als PDF oder Word-Dokument exportiert werden.', + }, +}; + +interface Props { + isOpen: boolean; + onClose: () => void; + pageKey: string; +} + +export function HelpPanel({ isOpen, onClose, pageKey }: Props) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const messagesEndRef = useRef(null); + + const helpContent = HELP_TEXTS[pageKey] ?? { + title: 'Hilfe', + text: 'Wie kann ich Ihnen helfen? Stellen Sie eine Frage im Chat unten.', + }; + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (!isOpen) return null; + + const sendMessage = async () => { + const text = input.trim(); + if (!text || loading) return; + + const newMessages: ChatMessage[] = [...messages, { role: 'user', content: text }]; + setMessages(newMessages); + setInput(''); + setLoading(true); + setError(null); + + try { + const res = await api.post<{ reply: string }>('/help/chat', { + messages: newMessages, + context: helpContent.title, + }); + setMessages(prev => [...prev, { role: 'assistant', content: res.data.reply }]); + } catch { + setError('KI-Assistent nicht verfügbar. Bitte prüfen Sie die Konfiguration.'); + } finally { + setLoading(false); + } + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+ ❓ Hilfe + +
+ + {/* Content */} +
+

+ 📖 {helpContent.title} +

+

+ {helpContent.text} +

+
+ + {/* Chat */} +
+

+ 🤖 KI-Assistent +

+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +

+ Stellen Sie eine Frage zur Bedienung von INSIGHT… +

+ )} + {messages.map((msg, i) => ( +
+ {msg.content} +
+ ))} + {loading && ( +
+ Denkt nach… +
+ )} + {error && ( +
{error}
+ )} +
+
+ + {/* Input */} +
+