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:
Thomas Reitz 2026-03-15 10:39:30 +01:00
parent 69305a0b0b
commit 3f919340b5
31 changed files with 2178 additions and 6 deletions

View file

@ -1,5 +1,5 @@
# INSIGHT MVP — Aktueller Implementierungsstand # INSIGHT MVP — Aktueller Implementierungsstand
*Stand: 2026-03-15* *Stand: 2026-03-15 (Update)*
--- ---
@ -87,6 +87,27 @@
- ✅ SSL/Domain (`/admin/ssl`) - ✅ SSL/Domain (`/admin/ssl`)
- ✅ Profilzugriff (`/admin/profile-access`) - ✅ Profilzugriff (`/admin/profile-access`)
- ✅ CRM Sichtbarkeit (`/admin/crm-settings`) - ✅ 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 ### Login-Screen-Branding
- ✅ Dynamischer Hintergrund aus Branding-Einstellungen - ✅ Dynamischer Hintergrund aus Branding-Einstellungen
@ -111,12 +132,16 @@
| Container | Docker Compose | | Container | Docker Compose |
### Prisma-Schemas ### 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 - `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 ### Branching
- Aktiver Branch: `feature/crm-service` - 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 POST /api/v1/settings/branding
GET /api/v1/settings/company GET /api/v1/settings/company
POST /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) ## API-Endpunkte (CRM Service)
``` ```
Companies, Contacts, Deals, Activities, Pipelines, ... (vollständiges CRUD) 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 GET /api/v1/crm/visibility-settings
PUT /api/v1/crm/visibility-settings/:entity PUT /api/v1/crm/visibility-settings/:entity
GET /api/v1/crm/office365/emails GET /api/v1/crm/office365/emails

View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@azure/msal-node": "^5.0.6", "@azure/msal-node": "^5.0.6",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",
@ -229,6 +230,36 @@
"tslib": "^2.1.0" "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": { "node_modules/@azure/msal-common": {
"version": "16.2.0", "version": "16.2.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz",
@ -3200,6 +3231,16 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/passport": {
"version": "1.0.17", "version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@ -3749,6 +3790,18 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC" "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -3824,6 +3877,18 @@
"node": ">= 6.0.0" "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": { "node_modules/ajv": {
"version": "8.12.0", "version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@ -4002,6 +4067,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -4847,6 +4918,18 @@
"color-support": "bin.js" "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": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -5182,6 +5265,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@ -5480,6 +5572,21 @@
"node": ">= 0.4" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -5836,6 +5943,15 @@
"node": ">= 0.6" "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": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -6326,6 +6442,41 @@
"node": "*" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -6743,6 +6894,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@ -6830,6 +6996,15 @@
"node": ">=10.17.0" "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": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -8755,6 +8930,26 @@
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT" "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": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@ -11521,6 +11716,15 @@
"defaults": "^1.0.3" "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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View file

@ -26,6 +26,7 @@
"prisma:seed": "ts-node prisma/seed.ts" "prisma:seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@azure/msal-node": "^5.0.6", "@azure/msal-node": "^5.0.6",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",

View file

@ -390,3 +390,53 @@ model ExpertAttachment {
@@map("expert_attachments") @@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")
}

View file

@ -13,6 +13,8 @@ import { ExpertProfileModule } from './core/expert-profile/expert-profile.module
import { SettingsModule } from './core/settings/settings.module'; import { SettingsModule } from './core/settings/settings.module';
import { IntegrationsModule } from './core/integrations/integrations.module'; import { IntegrationsModule } from './core/integrations/integrations.module';
import { ProfileAccessModule } from './core/profile-access/profile-access.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation'; import { validateConfig } from './config/env.validation';
@ -48,6 +50,8 @@ import { validateConfig } from './config/env.validation';
SettingsModule, SettingsModule,
IntegrationsModule, IntegrationsModule,
ProfileAccessModule, ProfileAccessModule,
MasterDataModule,
HelpModule,
], ],
providers: [ providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt // Global Guards: Alle Routen sind standardmaessig geschuetzt

View file

@ -112,6 +112,10 @@ class EnvironmentVariables {
@IsOptional() @IsOptional()
@IsString() @IsString()
INTEGRATION_ENCRYPTION_KEY?: string; INTEGRATION_ENCRYPTION_KEY?: string;
@IsOptional()
@IsString()
ANTHROPIC_API_KEY?: string;
} }
export function validateConfig( export function validateConfig(

View 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);
}
}

View 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 {}

View 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.');
}
}
}

View file

@ -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); }
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -82,6 +82,16 @@ export class ActivitiesController {
return { success: true, data: tasks, meta: { count: tasks.length } }; 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') @Get(':id')
@ApiOperation({ summary: 'Aktivitaet-Details abrufen' }) @ApiOperation({ summary: 'Aktivitaet-Details abrufen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })

View file

@ -212,6 +212,66 @@ export class ActivitiesService {
return this.prisma.activity.delete({ where: { id } }); 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) */ /** Alle offenen Aufgaben (TASK + FOLLOWUP, nicht erledigt) */
async findOpenTasks(tenantId: string) { async findOpenTasks(tenantId: string) {
return this.prisma.activity.findMany({ return this.prisma.activity.findMany({

View file

@ -25,6 +25,7 @@ import { CreateDealDto } from './dto/create-deal.dto';
import { UpdateDealDto } from './dto/update-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto';
import { QueryDealsDto } from './dto/query-deals.dto'; import { QueryDealsDto } from './dto/query-deals.dto';
import { ForecastQueryDto } from './dto/forecast-query.dto'; import { ForecastQueryDto } from './dto/forecast-query.dto';
import { StatsQueryDto } from './dto/stats-query.dto';
import { AddOwnerDto } from '../common/dto/owner.dto'; import { AddOwnerDto } from '../common/dto/owner.dto';
import { OwnersService } from '../owners/owners.service'; import { OwnersService } from '../owners/owners.service';
import { CurrentUser, JwtPayload } from '../common/decorators'; import { CurrentUser, JwtPayload } from '../common/decorators';
@ -86,6 +87,16 @@ export class DealsController {
return singleResponse(result); 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') @Get(':id')
@ApiOperation({ summary: 'Vorgangsdetails abrufen' }) @ApiOperation({ summary: 'Vorgangsdetails abrufen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })

View file

@ -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 } { private getPeriodBounds(period: ForecastPeriod): { start: Date; end: Date } {
const now = new Date(); const now = new Date();

View 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;
}

View file

@ -15,7 +15,8 @@
"axios": "^1.7.0", "axios": "^1.7.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
@ -285,6 +286,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -1571,6 +1581,69 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2082,6 +2155,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2147,9 +2229,129 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2184,6 +2392,16 @@
"node": ">=0.4.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2560,6 +2778,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2567,6 +2791,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "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": ">=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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -3017,6 +3259,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3132,6 +3380,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3275,6 +3532,23 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -3318,6 +3592,12 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -3360,6 +3640,69 @@
"react-dom": ">=16.8" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3496,6 +3839,12 @@
"node": ">=8" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3601,6 +3950,28 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View file

@ -20,7 +20,8 @@
"axios": "^1.7.0", "axios": "^1.7.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^18.3.0", "@types/react": "^18.3.0",

View file

@ -11,6 +11,7 @@ const tabs = [
{ to: '/admin/ssl', label: 'SSL / Domain' }, { to: '/admin/ssl', label: 'SSL / Domain' },
{ to: '/admin/profile-access', label: 'Profilzugriff' }, { to: '/admin/profile-access', label: 'Profilzugriff' },
{ to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' }, { to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' },
{ to: '/admin/master-data', label: 'Stammdaten' },
]; ];
export function AdminLayout() { export function AdminLayout() {

View 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>
);
}

View 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>
</>
);
}

View file

@ -0,0 +1 @@
export { HelpPanel } from './HelpPanel';

View 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>
);
}

View file

@ -0,0 +1 @@
export { HelpTooltip } from './HelpTooltip';

View file

@ -82,6 +82,8 @@ import type {
M365Contact, M365Contact,
M365MailFolder, M365MailFolder,
M365UserProfile, M365UserProfile,
DealStats,
ActivityStats,
} from './types'; } from './types';
// --- Contacts --- // --- Contacts ---
@ -154,6 +156,18 @@ export const dealsApi = {
.then((r) => r.data), .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 --- // --- Forecast ---
export const forecastApi = { export const forecastApi = {
@ -254,6 +268,14 @@ export const activitiesApi = {
'/crm/activities/open-tasks', '/crm/activities/open-tasks',
) )
.then((r) => r.data), .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 --- // --- Companies ---

View file

@ -28,6 +28,7 @@ import {
graphApi, graphApi,
office365Api, office365Api,
dealTypesApi, dealTypesApi,
dealStatsApi,
} from './api'; } from './api';
import type { import type {
ContactsQueryParams, ContactsQueryParams,
@ -1562,3 +1563,23 @@ export function useContactByEmail(email: string | null) {
retry: false, 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,
});
}

View 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>
);
}

View file

@ -1130,3 +1130,45 @@ export interface CrmContactLookup {
email: string | null; email: string | null;
companyName: 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;
}

View file

@ -16,6 +16,7 @@ import { AdminCompanyPage } from '../admin/AdminCompanyPage';
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage'; import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage'; import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage'; import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage';
import { AdminMasterDataPage } from '../admin/AdminMasterDataPage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
@ -30,6 +31,7 @@ import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
import { ForecastPage } from '../crm/forecast/ForecastPage'; import { ForecastPage } from '../crm/forecast/ForecastPage';
import { KanbanPage } from '../crm/deals/KanbanPage'; import { KanbanPage } from '../crm/deals/KanbanPage';
import { Office365Page } from '../crm/office365/Office365Page'; import { Office365Page } from '../crm/office365/Office365Page';
import { ReportsPage } from '../crm/reports/ReportsPage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@ -79,6 +81,7 @@ export function App() {
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} /> <Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} /> <Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
<Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></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/office365" element={<Office365Page />} />
<Route path="crm/import" element={<Navigate to="/crm/settings" replace />} /> <Route path="crm/import" element={<Navigate to="/crm/settings" replace />} />
<Route path="crm/settings" element={<CrmSettingsPage />} /> <Route path="crm/settings" element={<CrmSettingsPage />} />
@ -96,6 +99,7 @@ export function App() {
<Route path="ssl" element={<AdminSslPage />} /> <Route path="ssl" element={<AdminSslPage />} />
<Route path="profile-access" element={<AdminProfileAccessPage />} /> <Route path="profile-access" element={<AdminProfileAccessPage />} />
<Route path="crm-settings" element={<AdminCrmSettingsPage />} /> <Route path="crm-settings" element={<AdminCrmSettingsPage />} />
<Route path="master-data" element={<AdminMasterDataPage />} />
</Route> </Route>
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */} {/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} /> <Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { HelpPanel } from '../components/HelpPanel';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
@ -130,6 +131,8 @@ export function AppLayout() {
isModuleEnabled('pipelines'); isModuleEnabled('pipelines');
const [crmOpen, setCrmOpen] = useState(true); const [crmOpen, setCrmOpen] = useState(true);
const [appsOpen, setAppsOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const pageKey = location.pathname.replace(/^\//, '').replace(/\//g, '-');
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem('sidebar-collapsed') === 'true'; return localStorage.getItem('sidebar-collapsed') === 'true';
}); });
@ -451,6 +454,31 @@ export function AppLayout() {
{!collapsed && 'Kanban'} {!collapsed && 'Kanban'}
</NavLink> </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 <NavLink
to="/crm/office365" to="/crm/office365"
className={({ isActive }) => className={({ isActive }) =>
@ -605,6 +633,9 @@ export function AppLayout() {
</aside> </aside>
{/* Hilfe-Panel */}
<HelpPanel isOpen={helpOpen} onClose={() => setHelpOpen(false)} pageKey={pageKey} />
{/* Main Content */} {/* Main Content */}
<main <main
className={styles.main} className={styles.main}
@ -628,6 +659,29 @@ export function AppLayout() {
))} ))}
</div> </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) */} {/* Benutzer-Profil (klickbar → /profile) */}
<div <div
className={styles.topbarUser} className={styles.topbarUser}