mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat: Stammdaten, CRM Reporting, Hilfesystem (hohe Priorität)
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 <noreply@anthropic.com>
This commit is contained in:
parent
69305a0b0b
commit
3f919340b5
31 changed files with 2178 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
204
packages/core-service/package-lock.json
generated
204
packages/core-service/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ class EnvironmentVariables {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
INTEGRATION_ENCRYPTION_KEY?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
}
|
||||
|
||||
export function validateConfig(
|
||||
|
|
|
|||
18
packages/core-service/src/core/help/help.controller.ts
Normal file
18
packages/core-service/src/core/help/help.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
9
packages/core-service/src/core/help/help.module.ts
Normal file
9
packages/core-service/src/core/help/help.module.ts
Normal file
|
|
@ -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 {}
|
||||
57
packages/core-service/src/core/help/help.service.ts
Normal file
57
packages/core-service/src/core/help/help.service.ts
Normal file
|
|
@ -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<string>('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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CreateDepartmentDto> & { 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<CreateLocationDto> & { 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<CreateCostCenterDto> & { 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<CreateJobTitleDto> & { 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<CreateSkillCategoryDto> & { 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); }
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<CreateDepartmentDto> & { 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<CreateLocationDto> & { 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<CreateCostCenterDto> & { 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<CreateJobTitleDto> & { 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<CreateSkillCategoryDto> & { 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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<string, { total: number; completed: number }>();
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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<string, number>();
|
||||
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();
|
||||
|
||||
|
|
|
|||
15
packages/crm-service/src/deals/dto/stats-query.dto.ts
Normal file
15
packages/crm-service/src/deals/dto/stats-query.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
375
packages/frontend/package-lock.json
generated
375
packages/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
245
packages/frontend/src/admin/AdminMasterDataPage.tsx
Normal file
245
packages/frontend/src/admin/AdminMasterDataPage.tsx
Normal file
|
|
@ -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<string | null>(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<MasterItem[]>({
|
||||
queryKey: ['master-data', tabKey],
|
||||
queryFn: () => api.get<MasterItem[]>(`/master-data/${tabKey}`).then(r => r.data),
|
||||
});
|
||||
|
||||
const invalidate = () => { void qc.invalidateQueries({ queryKey: ['master-data', tabKey] }); };
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => api.post(`/master-data/${tabKey}`, data),
|
||||
onSuccess: () => { invalidate(); setAdding(false); setNewName(''); setNewCode(''); setNewColor('#4f46e5'); },
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
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<string, unknown> = { 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<string, unknown> = { name: newName };
|
||||
if (isCostCenter) data.code = newCode;
|
||||
if (isSkillCat) data.color = newColor;
|
||||
createMut.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) return <p style={{ color: 'var(--color-text-secondary)' }}>Lädt…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Liste */}
|
||||
{items.length === 0 && !adding && (
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
Noch keine Einträge vorhanden.
|
||||
</p>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.5rem 0',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
{editId === item.id ? (
|
||||
<>
|
||||
{isCostCenter && (
|
||||
<input
|
||||
style={{ ...inputStyle, width: 80 }}
|
||||
value={editCode}
|
||||
onChange={e => setEditCode(e.target.value)}
|
||||
placeholder="Code"
|
||||
/>
|
||||
)}
|
||||
{isSkillCat && (
|
||||
<input type="color" value={editColor} onChange={e => setEditColor(e.target.value)}
|
||||
style={{ width: 36, height: 28, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
|
||||
)}
|
||||
<input
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveEdit(item.id); if (e.key === 'Escape') setEditId(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
<button style={btnStyle('primary')} onClick={() => saveEdit(item.id)} disabled={updateMut.isPending}>Speichern</button>
|
||||
<button style={btnStyle('ghost')} onClick={() => setEditId(null)}>Abbrechen</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isSkillCat && item.color && (
|
||||
<span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: '50%', background: item.color, border: '1px solid var(--color-border)' }} />
|
||||
)}
|
||||
{isCostCenter && item.code && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'var(--color-text-secondary)', background: 'var(--color-bg)', padding: '0.1rem 0.4rem', borderRadius: 4, border: '1px solid var(--color-border)' }}>
|
||||
{item.code}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1, fontSize: '0.875rem' }}>{item.name}</span>
|
||||
<button style={btnStyle('ghost')} onClick={() => startEdit(item)}>✏ Bearbeiten</button>
|
||||
<button style={btnStyle('danger')} onClick={() => { if (confirm(`"${item.name}" wirklich löschen?`)) deleteMut.mutate(item.id); }}
|
||||
disabled={deleteMut.isPending}>🗑</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Neu-Formular */}
|
||||
{adding ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
{isCostCenter && (
|
||||
<input style={{ ...inputStyle, width: 80 }} value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="Code" autoFocus />
|
||||
)}
|
||||
{isSkillCat && (
|
||||
<input type="color" value={newColor} onChange={e => setNewColor(e.target.value)}
|
||||
style={{ width: 36, height: 28, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 2 }} />
|
||||
)}
|
||||
<input
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setAdding(false); setNewName(''); } }}
|
||||
placeholder="Name eingeben…"
|
||||
autoFocus={!isCostCenter && !isSkillCat}
|
||||
/>
|
||||
<button style={btnStyle('primary')} onClick={handleCreate} disabled={createMut.isPending || !newName.trim()}>
|
||||
{createMut.isPending ? '…' : 'Hinzufügen'}
|
||||
</button>
|
||||
<button style={btnStyle('ghost')} onClick={() => { setAdding(false); setNewName(''); setNewCode(''); }}>Abbrechen</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={{ ...btnStyle('primary'), marginTop: '1rem' }}
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
+ Neuer Eintrag
|
||||
</button>
|
||||
)}
|
||||
{createMut.isError && (
|
||||
<p style={{ color: 'var(--color-danger, #dc2626)', fontSize: '0.8125rem', marginTop: '0.5rem' }}>Fehler beim Erstellen</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMasterDataPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('departments');
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700 }}>
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1.125rem', fontWeight: 700 }}>Stammdaten</h2>
|
||||
<p style={{ margin: '0 0 1.5rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Verwaltete Referenzlisten für Abteilungen, Standorte, Kostenstellen und mehr.
|
||||
</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '2px solid var(--color-border)', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
style={{
|
||||
padding: '0.5rem 0.875rem',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: activeTab === t.key ? 700 : 400,
|
||||
color: activeTab === t.key ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
borderBottom: activeTab === t.key ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MasterDataTab key={activeTab} tabKey={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
packages/frontend/src/components/HelpPanel/HelpPanel.tsx
Normal file
223
packages/frontend/src/components/HelpPanel/HelpPanel.tsx
Normal file
|
|
@ -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<string, { title: string; text: string }> = {
|
||||
'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<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1040,
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0,
|
||||
width: 340, zIndex: 1050,
|
||||
background: 'var(--color-bg-card)',
|
||||
borderLeft: '1px solid var(--color-border)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
fontFamily: 'inherit',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem' }}>❓ Hilfe</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: '1.25rem', color: 'var(--color-text-secondary)', padding: '0.25rem',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
<p style={{ margin: '0 0 0.375rem', fontSize: '0.75rem', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
📖 {helpContent.title}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '0.8125rem', color: 'var(--color-text)', lineHeight: 1.5 }}>
|
||||
{helpContent.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chat */}
|
||||
<div style={{ padding: '1rem 1.25rem 0.5rem', flexShrink: 0 }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.75rem', fontWeight: 700, color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
🤖 KI-Assistent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 1.25rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{messages.length === 0 && (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
|
||||
Stellen Sie eine Frage zur Bedienung von INSIGHT…
|
||||
</p>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} style={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
background: msg.role === 'user' ? 'var(--color-primary)' : 'var(--color-bg)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--color-text)',
|
||||
border: msg.role === 'assistant' ? '1px solid var(--color-border)' : 'none',
|
||||
borderRadius: msg.role === 'user' ? '12px 12px 4px 12px' : '12px 12px 12px 4px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div style={{ alignSelf: 'flex-start', fontSize: '0.8125rem', color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
|
||||
Denkt nach…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-danger, #dc2626)' }}>{error}</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{
|
||||
padding: '0.75rem 1.25rem 1rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex', gap: '0.5rem', flexShrink: 0,
|
||||
}}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }}
|
||||
placeholder="Frage eingeben… (Enter zum Senden)"
|
||||
rows={2}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
padding: '0.5rem 0.6rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!input.trim() || loading}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
alignSelf: 'flex-end',
|
||||
opacity: (!input.trim() || loading) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
packages/frontend/src/components/HelpPanel/index.ts
Normal file
1
packages/frontend/src/components/HelpPanel/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { HelpPanel } from './HelpPanel';
|
||||
51
packages/frontend/src/components/HelpTooltip/HelpTooltip.tsx
Normal file
51
packages/frontend/src/components/HelpTooltip/HelpTooltip.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
position?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
export function HelpTooltip({ text, position = 'top' }: Props) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
zIndex: 9999,
|
||||
background: 'var(--color-text, #1a1a2e)',
|
||||
color: '#fff',
|
||||
padding: '0.4rem 0.6rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxWidth: 220,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
pointerEvents: 'none',
|
||||
...(position === 'top' ? { bottom: 'calc(100% + 6px)', left: '50%', transform: 'translateX(-50%)' } : {}),
|
||||
...(position === 'bottom' ? { top: 'calc(100% + 6px)', left: '50%', transform: 'translateX(-50%)' } : {}),
|
||||
...(position === 'right' ? { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' } : {}),
|
||||
...(position === 'left' ? { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', marginLeft: '0.25rem' }}>
|
||||
<span
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
style={{
|
||||
cursor: 'help',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
aria-label={text}
|
||||
role="tooltip"
|
||||
>
|
||||
❓
|
||||
</span>
|
||||
{show && <div style={tooltipStyle}>{text}</div>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
packages/frontend/src/components/HelpTooltip/index.ts
Normal file
1
packages/frontend/src/components/HelpTooltip/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { HelpTooltip } from './HelpTooltip';
|
||||
|
|
@ -82,6 +82,8 @@ import type {
|
|||
M365Contact,
|
||||
M365MailFolder,
|
||||
M365UserProfile,
|
||||
DealStats,
|
||||
ActivityStats,
|
||||
} from './types';
|
||||
|
||||
// --- Contacts ---
|
||||
|
|
@ -154,6 +156,18 @@ export const dealsApi = {
|
|||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Deal Stats ---
|
||||
|
||||
export const dealStatsApi = {
|
||||
get: (period?: string) =>
|
||||
api
|
||||
.get<{ success: boolean; data: DealStats; meta: { timestamp: string } }>(
|
||||
'/crm/deals/stats',
|
||||
{ params: { period } },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Forecast ---
|
||||
|
||||
export const forecastApi = {
|
||||
|
|
@ -254,6 +268,14 @@ export const activitiesApi = {
|
|||
'/crm/activities/open-tasks',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
getStats: (period?: string) =>
|
||||
api
|
||||
.get<{ success: boolean; data: ActivityStats; meta: { timestamp: string } }>(
|
||||
'/crm/activities/stats',
|
||||
{ params: { period } },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Companies ---
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
graphApi,
|
||||
office365Api,
|
||||
dealTypesApi,
|
||||
dealStatsApi,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -1562,3 +1563,23 @@ export function useContactByEmail(email: string | null) {
|
|||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reporting / Stats
|
||||
// ============================================================
|
||||
|
||||
export function useDealStats(period?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['crm', 'deals', 'stats', period],
|
||||
queryFn: () => dealStatsApi.get(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivityStats(period?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['crm', 'activities', 'stats', period],
|
||||
queryFn: () => activitiesApi.getStats(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
290
packages/frontend/src/crm/reports/ReportsPage.tsx
Normal file
290
packages/frontend/src/crm/reports/ReportsPage.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Line,
|
||||
Bar,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { useDealStats, useActivityStats, useForecast } from '../hooks';
|
||||
|
||||
type Period = 'MONTH' | 'QUARTER' | 'YEAR';
|
||||
type TabKey = 'deals' | 'activities';
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
MONTH: 'Dieser Monat',
|
||||
QUARTER: 'Dieses Quartal',
|
||||
YEAR: 'Dieses Jahr',
|
||||
};
|
||||
|
||||
const COLOR_WON = '#16a34a';
|
||||
const COLOR_LOST = '#dc2626';
|
||||
const COLOR_PRIMARY = '#1040bb';
|
||||
const COLOR_OPEN = '#f59e0b';
|
||||
const PIE_COLORS = ['#dc2626', '#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16'];
|
||||
|
||||
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.25rem',
|
||||
};
|
||||
|
||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div style={{ ...cardStyle, minWidth: 130 }}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginBottom: '0.25rem' }}>{label}</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DealsTab({ period }: { period: Period }) {
|
||||
const { data: statsRes, isLoading } = useDealStats(period);
|
||||
const { data: forecastRes } = useForecast(undefined, undefined);
|
||||
|
||||
const stats = statsRes?.data;
|
||||
const stageData = forecastRes?.data?.stages ?? [];
|
||||
|
||||
if (isLoading) return <p style={{ color: 'var(--color-text-secondary)' }}>Lädt…</p>;
|
||||
if (!stats) return <p style={{ color: 'var(--color-text-secondary)' }}>Keine Daten verfügbar.</p>;
|
||||
|
||||
const { totalWon, totalLost, openDeals, winRate, totalRevenue, avgDealValue, lostByReason, monthlyTrend } = stats;
|
||||
|
||||
const formattedRevenue = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(totalRevenue);
|
||||
const formattedAvg = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(avgDealValue);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* Metric Cards */}
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<MetricCard label="Gewonnen" value={totalWon} />
|
||||
<MetricCard label="Verloren" value={totalLost} />
|
||||
<MetricCard label="Offen" value={openDeals} />
|
||||
<MetricCard label="Win Rate" value={`${winRate}%`} />
|
||||
<MetricCard label="Umsatz" value={formattedRevenue} />
|
||||
<MetricCard label="Ø Deal-Wert" value={formattedAvg} />
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend */}
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Monatlicher Trend (12 Monate)</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={monthlyTrend} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="won" stroke={COLOR_WON} name="Gewonnen" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="lost" stroke={COLOR_LOST} name="Verloren" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lost by Reason (PieChart) */}
|
||||
{lostByReason.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Verlustgründe</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={lostByReason} dataKey="count" nameKey="reason" cx="50%" cy="50%" outerRadius={75} label={({ reason, percent }) => `${reason} ${Math.round((percent ?? 0) * 100)}%`}>
|
||||
{lostByReason.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Pipeline stages */}
|
||||
{stageData.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Pipeline-Stages (Forecast)</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={stageData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="stageName" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="dealCount" fill={COLOR_PRIMARY} name="Deals" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue trend */}
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Umsatz-Trend (EUR)</h3>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyTrend} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(Number(value))} />
|
||||
<Bar dataKey="revenue" fill={COLOR_WON} name="Umsatz (EUR)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitiesTab({ period }: { period: Period }) {
|
||||
const { data: statsRes, isLoading } = useActivityStats(period);
|
||||
|
||||
const stats = statsRes?.data;
|
||||
|
||||
if (isLoading) return <p style={{ color: 'var(--color-text-secondary)' }}>Lädt…</p>;
|
||||
if (!stats) return <p style={{ color: 'var(--color-text-secondary)' }}>Keine Daten verfügbar.</p>;
|
||||
|
||||
const { totalActivities, totalCompleted, overallCompletionRate, byType, upcomingTasks } = stats;
|
||||
|
||||
const activityTypeLabels: Record<string, string> = {
|
||||
NOTE: 'Notiz',
|
||||
CALL: 'Anruf',
|
||||
EMAIL: 'E-Mail',
|
||||
MEETING: 'Meeting',
|
||||
TASK: 'Aufgabe',
|
||||
FOLLOWUP: 'Follow-Up',
|
||||
};
|
||||
|
||||
const chartData = byType.map(b => ({
|
||||
type: activityTypeLabels[b.type] ?? b.type,
|
||||
total: b.total,
|
||||
erledigt: b.completed,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* Metric Cards */}
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<MetricCard label="Gesamt" value={totalActivities} />
|
||||
<MetricCard label="Erledigt" value={totalCompleted} />
|
||||
<MetricCard label="Erledigungsrate" value={`${overallCompletionRate}%`} />
|
||||
<MetricCard label="Offene Aufgaben (7 Tage)" value={upcomingTasks} />
|
||||
</div>
|
||||
|
||||
{/* By Type BarChart */}
|
||||
{chartData.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Aktivitäten nach Typ</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="type" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="total" fill={COLOR_PRIMARY} name="Gesamt" />
|
||||
<Bar dataKey="erledigt" fill={COLOR_WON} name="Erledigt" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Rate per type */}
|
||||
{byType.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 1rem', fontSize: '0.875rem', fontWeight: 600 }}>Erledigungsrate nach Typ (%)</h3>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart
|
||||
data={byType.map(b => ({ type: activityTypeLabels[b.type] ?? b.type, rate: b.completionRate }))}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="type" tick={{ fontSize: 12 }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v) => `${v}%`} />
|
||||
<Bar dataKey="rate" fill={COLOR_OPEN} name="Erledigungsrate %" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const [period, setPeriod] = useState<Period>('YEAR');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('deals');
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700 }}>CRM Reports</h2>
|
||||
<p style={{ margin: '0.25rem 0 0', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Vertriebsanalyse und Aktivitätsstatistiken
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{(Object.keys(PERIOD_LABELS) as Period[]).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
style={{
|
||||
padding: '0.4rem 0.85rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: period === p ? 'var(--color-primary)' : 'var(--color-bg-card)',
|
||||
color: period === p ? '#fff' : 'var(--color-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: period === p ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{PERIOD_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '2px solid var(--color-border)', marginBottom: '1.5rem' }}>
|
||||
{([['deals', 'Deals'], ['activities', 'Aktivitäten']] as [TabKey, string][]).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: activeTab === key ? 700 : 400,
|
||||
color: activeTab === key ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
borderBottom: activeTab === key ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'deals' && <DealsTab period={period} />}
|
||||
{activeTab === 'activities' && <ActivitiesTab period={period} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1130,3 +1130,45 @@ export interface CrmContactLookup {
|
|||
email: string | null;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
||||
// --- Reporting / Stats ---
|
||||
|
||||
export interface MonthlyTrendEntry {
|
||||
month: string;
|
||||
won: number;
|
||||
lost: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface LostByReasonEntry {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DealStats {
|
||||
period: string;
|
||||
totalWon: number;
|
||||
totalLost: number;
|
||||
openDeals: number;
|
||||
winRate: number;
|
||||
totalRevenue: number;
|
||||
avgDealValue: number;
|
||||
lostByReason: LostByReasonEntry[];
|
||||
monthlyTrend: MonthlyTrendEntry[];
|
||||
}
|
||||
|
||||
export interface ActivityStatsByType {
|
||||
type: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface ActivityStats {
|
||||
period: string;
|
||||
totalActivities: number;
|
||||
totalCompleted: number;
|
||||
overallCompletionRate: number;
|
||||
byType: ActivityStatsByType[];
|
||||
upcomingTasks: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
|||
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
|
||||
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
|
||||
import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage';
|
||||
import { AdminMasterDataPage } from '../admin/AdminMasterDataPage';
|
||||
import { ProfilePage } from '../profile/ProfilePage';
|
||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||
|
|
@ -30,6 +31,7 @@ import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
|
|||
import { ForecastPage } from '../crm/forecast/ForecastPage';
|
||||
import { KanbanPage } from '../crm/deals/KanbanPage';
|
||||
import { Office365Page } from '../crm/office365/Office365Page';
|
||||
import { ReportsPage } from '../crm/reports/ReportsPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
|
@ -79,6 +81,7 @@ export function App() {
|
|||
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/reports" element={<CrmModuleGuard module="deals"><ReportsPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/office365" element={<Office365Page />} />
|
||||
<Route path="crm/import" element={<Navigate to="/crm/settings" replace />} />
|
||||
<Route path="crm/settings" element={<CrmSettingsPage />} />
|
||||
|
|
@ -96,6 +99,7 @@ export function App() {
|
|||
<Route path="ssl" element={<AdminSslPage />} />
|
||||
<Route path="profile-access" element={<AdminProfileAccessPage />} />
|
||||
<Route path="crm-settings" element={<AdminCrmSettingsPage />} />
|
||||
<Route path="master-data" element={<AdminMasterDataPage />} />
|
||||
</Route>
|
||||
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
|
||||
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { HelpPanel } from '../components/HelpPanel';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
|
@ -130,6 +131,8 @@ export function AppLayout() {
|
|||
isModuleEnabled('pipelines');
|
||||
const [crmOpen, setCrmOpen] = useState(true);
|
||||
const [appsOpen, setAppsOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const pageKey = location.pathname.replace(/^\//, '').replace(/\//g, '-');
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
});
|
||||
|
|
@ -451,6 +454,31 @@ export function AppLayout() {
|
|||
{!collapsed && 'Kanban'}
|
||||
</NavLink>
|
||||
)}
|
||||
{isModuleEnabled('deals') && (
|
||||
<NavLink
|
||||
to="/crm/reports"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
title="Reports"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="9" width="3" height="6" rx="0.5" />
|
||||
<rect x="6" y="5" width="3" height="10" rx="0.5" />
|
||||
<rect x="11" y="1" width="3" height="14" rx="0.5" />
|
||||
</svg>
|
||||
{!collapsed && 'Reports'}
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink
|
||||
to="/crm/office365"
|
||||
className={({ isActive }) =>
|
||||
|
|
@ -605,6 +633,9 @@ export function AppLayout() {
|
|||
|
||||
</aside>
|
||||
|
||||
{/* Hilfe-Panel */}
|
||||
<HelpPanel isOpen={helpOpen} onClose={() => setHelpOpen(false)} pageKey={pageKey} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
className={styles.main}
|
||||
|
|
@ -628,6 +659,29 @@ export function AppLayout() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Hilfe-Button */}
|
||||
<button
|
||||
className={styles.topbarIconBtn}
|
||||
onClick={() => setHelpOpen(true)}
|
||||
title="Hilfe"
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
<path d="M6 6a2 2 0 114 0c0 1-1 1.5-2 2.5" />
|
||||
<circle cx="8" cy="12" r="0.75" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Benutzer-Profil (klickbar → /profile) */}
|
||||
<div
|
||||
className={styles.topbarUser}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue