Compare commits

...

62 Commits

Author SHA1 Message Date
318b6198da feat(portal) : add drag & drop status change on client ticket kanban
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:16:22 +01:00
4e3e854aa2 fix(portal) : allow admin to edit tickets and enable document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:12:55 +01:00
49cd971e3e feat(project) : add client tickets panel to project page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:28 +01:00
ffe4a0117c feat(portal) : allow client to edit own tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:25 +01:00
d2f6d84d03 feat(portal) : replace ticket list with kanban board
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:23 +01:00
2a874046d3 feat : allow client to edit own tickets and protect status fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:35:11 +01:00
f09ef67117 feat : date filter, project drawer, and misc frontend improvements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:26 +01:00
046ee396d3 feat(fixtures) : add users alice/bob/charlie and distribute task assignees
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:14 +01:00
0ba487cfa9 feat(fixtures) : add client users, client tickets, and ticket-task link
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:20:27 +01:00
a2fc8e6e52 feat(task) : add client ticket selector in TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:14:56 +01:00
6c910e7fcc fix : use native SQL for JSON roles query in PostgreSQL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:11:54 +01:00
6d7e6f5f48 fix : allow admin users to create client tickets on any project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:07:19 +01:00
0c8fb654a9 fix(portal) : allow admin+client users to access both views and add admin link
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:06:09 +01:00
f8748c4061 fix(portal) : handle ticket creation error and hide new ticket button for admins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:04:20 +01:00
2c28a4ad1d fix(notification) : add route priority to prevent API Platform conflict
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:59:47 +01:00
cf1cf1ff5c chore : add MCP bundle config files and .mcp.json for Claude Code
Auto-generated by Symfony Flex recipe + .mcp.json for local STDIO transport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:16 +01:00
0724d38a26 feat(frontend) : add portal pages, update auth middleware and DTOs for client portal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:51:51 +01:00
17c5160f2c docs : add MCP server documentation to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:49:57 +01:00
40d6f7693f feat(i18n) : add notification translations in French 2026-03-15 19:48:27 +01:00
e63ed63dd8 feat(frontend) : integrate NotificationBell in AppTopNav navbar 2026-03-15 19:48:13 +01:00
ad8142ac9d feat(frontend) : add NotificationBell component with dropdown 2026-03-15 19:48:03 +01:00
f7afe1c6fb docs : add MCP server section to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:47:43 +01:00
697075eea2 feat(frontend) : add useNotifications composable with polling 2026-03-15 19:47:33 +01:00
587733e6f9 feat(frontend) : add notification DTO and service 2026-03-15 19:47:21 +01:00
59b11f1225 feat(notification) : hook NotificationService into ticket processors 2026-03-15 19:47:06 +01:00
4094048aba feat(notification) : add NotificationService and UserRepository::findByRole 2026-03-15 19:46:37 +01:00
ce2eaa03e1 feat(notification) : add unread-count and mark-all-read custom controllers 2026-03-15 19:46:10 +01:00
d932359024 feat(notification) : add NotificationProvider filtered by current user 2026-03-15 19:45:58 +01:00
669c36cea1 feat(notification) : add Notification entity, repository, and migration 2026-03-15 19:45:47 +01:00
3d1a510d82 feat : add 22 MCP tool classes for projects, tasks, and time tracking
Tools: list-users, list-clients, list/get/create/update-project,
list/get/create/update/delete-task, list-statuses/priorities/efforts/tags,
list/create/update-group, list/create/update/delete-time-entry.

Attribute moved to class level for SDK discovery compatibility.
Install nyholm/psr7 for HTTP transport PSR-17 support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:45:39 +01:00
68dd9599a9 feat(auth) : redirect client users to /portal after login and extract ticket helpers composable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:53 +01:00
0d21e59023 feat(admin) : add client tickets tab with list, filters, status change, and delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:49 +01:00
7210a0d96f feat(kanban) : show client ticket icon on task cards, my-tasks, and task modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:46 +01:00
7099f1ca95 feat(documents) : generalize TaskDocumentUpload and add upload zone to ticket detail modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:43 +01:00
e16fd2053e feat : MCP server infrastructure setup
Install symfony/mcp-bundle, add STDIO + HTTP transport config,
API token auth on User entity with custom authenticator and firewall,
generate-api-token console command, Nginx /_mcp location, fixture token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:33:52 +01:00
760f5b6ad6 feat(frontend) : add i18n translations for client portal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:31:04 +01:00
adf050505d feat(frontend) : add client ticket support to task-documents service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:52 +01:00
12d043a50f feat(frontend) : add clientTicket to Task DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:38 +01:00
bfd418851e feat(frontend) : add client-tickets service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:32 +01:00
4fbbead3e3 feat(frontend) : add ClientTicket DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:19 +01:00
64961631e4 feat(frontend) : add client user management to admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:10 +01:00
7f2371e522 feat(frontend) : update UserData DTO for client users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:29:36 +01:00
851953df1e feat : generalize TaskDocumentProcessor for client tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:28:04 +01:00
b6cfe9d7d4 feat : add ClientTicketProvider with filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:24 +01:00
f33f2f95ec feat : add ClientTicketStatusProcessor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:10 +01:00
9a9416d6c8 fix : apply review fixes to MCP plan and spec
Fix getIsFinal() method name, enrich create/update tool return formats
to match get/list consistency, fix duplicate Reference section in spec,
correct tool count to 22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:27:06 +01:00
f27297517c feat : add ClientTicketNumberProcessor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:00 +01:00
d2e27a04ce feat : add ClientTicketRepository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:45 +01:00
10cde5e2f9 feat : add client portal migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:36 +01:00
926d6d54c5 feat : generalize TaskDocument for client tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:02 +01:00
a538bb3601 feat : add clientTicket relation to Task entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:50 +01:00
97dcff8542 feat : add ClientTicket entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:36 +01:00
87ab281099 feat : extend User entity with client and allowedProjects
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:10 +01:00
2b9095b1a2 docs : add MCP server implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:22:42 +01:00
05e24db6ca feat(security) : add role hierarchy for client portal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:21:28 +01:00
63febbea45 fix(security) : add ROLE_USER security on all read endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:21:19 +01:00
edc441f363 fix(security) : exclude ROLE_USER for ROLE_CLIENT users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:20:46 +01:00
f4eec2e6e9 docs : add client portal implementation plans (phases 1-3)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:18:25 +01:00
5547c67b30 docs : add create-group and update-group tools to MCP spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:17:44 +01:00
9e19adc09a docs : add HTTP transport + API token auth to MCP spec
Both STDIO (local) and HTTP (LAN) transports are now in scope.
HTTP secured by API token on User entity with custom authenticator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:15:20 +01:00
8d24949186 docs : update MCP server spec with review fixes
Adds list-users, list-clients, update-project tools. Fixes time entry
title as optional, adds startedAt to update-time-entry, adds taskId
filter, pagination limits, eager joins, security model docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:12:32 +01:00
c2fa308f1e docs : add MCP server design spec for Lesstime
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:08:27 +01:00
105 changed files with 14308 additions and 112 deletions

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
}
}
}

View File

@@ -15,6 +15,9 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration)
src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
src/Command/ # Commandes console (GenerateApiTokenCommand)
src/Repository/ # Repositories Doctrine
src/DataFixtures/ # Fixtures
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
@@ -80,8 +83,19 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
- 4 espaces d'indentation
### MCP Server
- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
- Générer un token : `php bin/console app:generate-api-token <username>`
- Config : `config/packages/mcp.yaml`, firewall dans `config/packages/security.yaml`
- Attribut `#[McpTool]` doit être sur la **classe** (pas la méthode `__invoke`) pour la discovery SDK
### Nginx
- `/_mcp` → Symfony (MCP HTTP transport)
- `/api/*` → Symfony (via try_files + index.php)
- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check`
- `/` → SPA frontend (`frontend/dist/`)
@@ -97,3 +111,4 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN)
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`

View File

@@ -1 +1,71 @@
# Lesstime
Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4.
## MCP Server
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps.
### Tools disponibles (22)
| Domaine | Tools |
|---------|-------|
| Reference | `list-users`, `list-clients` |
| Project | `list-projects`, `get-project`, `create-project`, `update-project` |
| Task | `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task` |
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
### Transports
| Transport | Usage | Auth |
|-----------|-------|------|
| **STDIO** | Claude Code sur la machine locale | Aucune |
| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer <token>`) |
### Configuration locale (STDIO)
```json
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
}
}
}
```
### Configuration réseau (HTTP)
```json
{
"mcpServers": {
"lesstime": {
"type": "url",
"url": "http://<ip-serveur>:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
}
}
}
```
### Gestion des tokens API
```bash
# Générer un token pour un utilisateur
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
```
### Mise en production (réseau local)
1. Déployer le code sur le serveur
2. `composer install --no-dev --optimize-autoloader`
3. `php bin/console doctrine:migrations:migrate --no-interaction`
4. `php bin/console cache:clear --env=prod`
5. `docker restart nginx-lesstime`
6. `php bin/console app:generate-api-token admin` — noter le token
7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement)
8. Configurer les clients MCP avec l'URL `http://<ip-serveur>:8082/_mcp` + le token

View File

@@ -14,7 +14,8 @@
"doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
@@ -23,6 +24,7 @@
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",

864
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4790d8c80c0fb208e5af11fb205c0202",
"content-hash": "75b9dbecf38167d0554dfd64a986a40e",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2549,6 +2549,82 @@
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "mcp/sdk",
"version": "v0.4.0",
"source": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/php-sdk.git",
"reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024",
"reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"opis/json-schema": "^2.4",
"php": "^8.1",
"php-http/discovery": "^1.20",
"phpdocumentor/reflection-docblock": "^5.6",
"psr/clock": "^1.0",
"psr/container": "^1.0 || ^2.0",
"psr/event-dispatcher": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"require-dev": {
"laminas/laminas-httphandlerrunner": "^2.12",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"phar-io/composer-distributor": "^1.0.2",
"php-cs-fixer/shim": "^3.91",
"phpdocumentor/shim": "^3",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.5",
"psr/simple-cache": "^2.0 || ^3.0",
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mcp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Christopher Hertel",
"email": "mail@christopher-hertel.de"
},
{
"name": "Kyrian Obikwelu",
"email": "koshnawaza@gmail.com"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
}
],
"description": "Model Context Protocol SDK for Client and Server applications in PHP",
"support": {
"issues": "https://github.com/modelcontextprotocol/php-sdk/issues",
"source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.4.0"
},
"time": "2026-02-23T21:42:54+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.1",
@@ -2614,6 +2690,353 @@
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "opis/json-schema",
"version": "2.6.0",
"source": {
"type": "git",
"url": "https://github.com/opis/json-schema.git",
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
"shasum": ""
},
"require": {
"ext-json": "*",
"opis/string": "^2.1",
"opis/uri": "^1.0",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"ext-bcmath": "*",
"ext-intl": "*",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\JsonSchema\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
},
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
}
],
"description": "Json Schema Validator for PHP",
"homepage": "https://opis.io/json-schema",
"keywords": [
"json",
"json-schema",
"schema",
"validation",
"validator"
],
"support": {
"issues": "https://github.com/opis/json-schema/issues",
"source": "https://github.com/opis/json-schema/tree/2.6.0"
},
"time": "2025-10-17T12:46:48+00:00"
},
{
"name": "opis/string",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/opis/string.git",
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"ext-json": "*",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\String\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
},
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
}
],
"description": "Multibyte strings as objects",
"homepage": "https://opis.io/string",
"keywords": [
"multi-byte",
"opis",
"string",
"string manipulation",
"utf-8"
],
"support": {
"issues": "https://github.com/opis/string/issues",
"source": "https://github.com/opis/string/tree/2.1.0"
},
"time": "2025-10-17T12:38:41+00:00"
},
{
"name": "opis/uri",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/opis/uri.git",
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
"shasum": ""
},
"require": {
"opis/string": "^2.0",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\Uri\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
},
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
}
],
"description": "Build, parse and validate URIs and URI-templates",
"homepage": "https://opis.io",
"keywords": [
"URI Template",
"parse url",
"punycode",
"uri",
"uri components",
"url",
"validate uri"
],
"support": {
"issues": "https://github.com/opis/uri/issues",
"source": "https://github.com/opis/uri/tree/1.1.0"
},
"time": "2021-05-22T15:57:08+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -2669,16 +3092,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "6.0.2",
"version": "5.6.6",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf"
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf",
"reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
"shasum": ""
},
"require": {
@@ -2686,8 +3109,8 @@
"ext-filter": "*",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2",
"phpdocumentor/type-resolver": "^2.0",
"phpstan/phpdoc-parser": "^2.0",
"phpdocumentor/type-resolver": "^1.7",
"phpstan/phpdoc-parser": "^1.7|^2.0",
"webmozart/assert": "^1.9.1 || ^2"
},
"require-dev": {
@@ -2697,8 +3120,7 @@
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
"psalm/phar": "^5.26",
"shipmonk/dead-code-detector": "^0.5.1"
"psalm/phar": "^5.26"
},
"type": "library",
"extra": {
@@ -2728,44 +3150,44 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2"
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
},
"time": "2026-03-01T18:43:49+00:00"
"time": "2025-12-22T21:13:58+00:00"
},
{
"name": "phpdocumentor/type-resolver",
"version": "2.0.0",
"version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
"php": "^7.4 || ^8.0",
"php": "^7.3 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
"phpstan/phpdoc-parser": "^2.0"
"phpstan/phpdoc-parser": "^1.18|^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-phpunit": "^1.1",
"phpunit/phpunit": "^9.5",
"psalm/phar": "^4"
"rector/rector": "^0.13.9",
"vimeo/psalm": "^4.25"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-1.x": "1.x-dev",
"dev-2.x": "2.x-dev"
"dev-1.x": "1.x-dev"
}
},
"autoload": {
@@ -2786,9 +3208,9 @@
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
"source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
"source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
},
"time": "2026-01-06T21:53:42+00:00"
"time": "2025-11-21T15:09:14+00:00"
},
{
"name": "phpstan/phpdoc-parser",
@@ -3037,6 +3459,227 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/http-server-handler",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-handler.git",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side request handler",
"keywords": [
"handler",
"http",
"http-interop",
"psr",
"psr-15",
"psr-7",
"request",
"response",
"server"
],
"support": {
"source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
},
"time": "2023-04-10T20:06:20+00:00"
},
{
"name": "psr/http-server-middleware",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-middleware.git",
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0",
"psr/http-server-handler": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side middleware",
"keywords": [
"http",
"http-interop",
"middleware",
"psr",
"psr-15",
"psr-7",
"request",
"response"
],
"support": {
"issues": "https://github.com/php-fig/http-server-middleware/issues",
"source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
},
"time": "2023-04-11T06:14:47+00:00"
},
{
"name": "psr/link",
"version": "2.0.1",
@@ -4976,6 +5619,90 @@
],
"time": "2026-03-06T16:58:46+00:00"
},
{
"name": "symfony/mcp-bundle",
"version": "v0.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/mcp-bundle.git",
"reference": "739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc",
"reference": "739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc",
"shasum": ""
},
"require": {
"mcp/sdk": "^0.4",
"php-http/discovery": "^1.20",
"symfony/config": "^7.3|^8.0",
"symfony/console": "^7.3|^8.0",
"symfony/dependency-injection": "^7.3|^8.0",
"symfony/framework-bundle": "^7.3|^8.0",
"symfony/http-foundation": "^7.3|^8.0",
"symfony/http-kernel": "^7.3|^8.0",
"symfony/psr-http-message-bridge": "^7.3|^8.0",
"symfony/routing": "^7.3|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.53",
"symfony/monolog-bundle": "^3.10 || ^4.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ai",
"name": "symfony/ai"
}
},
"autoload": {
"psr-4": {
"Symfony\\AI\\McpBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christopher Hertel",
"email": "mail@christopher-hertel.de"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)",
"support": {
"source": "https://github.com/symfony/mcp-bundle/tree/v0.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-04T16:39:24+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.6",
@@ -5631,6 +6358,93 @@
],
"time": "2026-03-04T15:54:04+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-03T23:40:55+00:00"
},
{
"name": "symfony/routing",
"version": "v8.0.6",

View File

@@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory

23
config/packages/mcp.yaml Normal file
View File

@@ -0,0 +1,23 @@
mcp:
app: 'lesstime'
version: '1.0.0'
description: 'Lesstime project management — projects, tasks, time tracking'
instructions: |
This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
Groups are PER-PROJECT (each group belongs to one project).
Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks.
Use list-users and list-clients to discover valid user and client IDs.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600

View File

@@ -1,4 +1,7 @@
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -25,6 +28,12 @@ security:
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
mcp:
pattern: ^/_mcp
stateless: true
provider: app_user_provider
custom_authenticators:
- App\Security\ApiTokenAuthenticator
api:
pattern: ^/api
stateless: true
@@ -50,6 +59,7 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:

View File

@@ -1610,6 +1610,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
* },
* }
* @psalm-type McpConfig = array{
* app?: scalar|Param|null, // Default: "app"
* version?: scalar|Param|null, // Default: "0.0.1"
* description?: scalar|Param|null, // Default: null
* icons?: list<array{ // Default: []
* src?: scalar|Param|null,
* mime_type?: scalar|Param|null, // Default: null
* sizes?: list<scalar|Param|null>,
* }>,
* website_url?: scalar|Param|null, // Default: null
* pagination_limit?: int|Param, // Default: 50
* instructions?: scalar|Param|null, // Default: null
* client_transports?: array{
* stdio?: bool|Param, // Default: false
* http?: bool|Param, // Default: false
* },
* discovery?: array{
* scan_dirs?: list<scalar|Param|null>,
* exclude_dirs?: list<scalar|Param|null>,
* },
* http?: array{
* path?: scalar|Param|null, // Default: "/_mcp"
* session?: array{
* store?: "file"|"memory"|"cache"|Param, // Default: "file"
* directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions"
* cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions"
* prefix?: scalar|Param|null, // Default: "mcp-"
* ttl?: int|Param, // Default: 3600
* },
* },
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1622,6 +1653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1634,6 +1666,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1647,6 +1680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1660,6 +1694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

3
config/routes/mcp.yaml Normal file
View File

@@ -0,0 +1,3 @@
mcp:
resource: .
type: mcp

View File

@@ -7,6 +7,11 @@ server {
client_max_body_size 55m;
location ^~ /_mcp {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,970 @@
# Client Portal Phase 3 — Notifications
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service).
**Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`.
> **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity.
**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript
**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md`
**Depends on:** Phase 1 + Phase 2
---
## Chunk 1: Notification Entity & Migration
### Task 1: Create the Notification entity
- [ ] **Create `src/Entity/Notification.php`** with the following content:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
provider: NotificationProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Patch(
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
),
],
normalizationContext: ['groups' => ['notification:read']],
denormalizationContext: ['groups' => ['notification:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['notification:read'])]
private ?User $user = null;
#[ORM\Column(length: 50)]
#[Groups(['notification:read'])]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['notification:read'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Groups(['notification:read'])]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['notification:read'])]
private ?ClientTicket $relatedTicket = null;
#[ORM\Column]
#[Groups(['notification:read', 'notification:write'])]
private bool $isRead = false;
#[ORM\Column]
#[Groups(['notification:read'])]
private ?DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getRelatedTicket(): ?ClientTicket
{
return $this->relatedTicket;
}
public function setRelatedTicket(?ClientTicket $relatedTicket): static
{
$this->relatedTicket = $relatedTicket;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): static
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}
```
### Task 2: Create the NotificationRepository
- [ ] **Create `src/Repository/NotificationRepository.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
public function countUnreadByUser(User $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
}
public function markAllReadByUser(User $user): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', 'true')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->executeStatement();
}
}
```
### Task 3: Generate and run the migration
- [ ] **Run inside the PHP container** (`make shell`):
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`.
- [ ] **Commit:**
```bash
git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/
git commit -m "feat(notification) : add Notification entity, repository, and migration"
```
---
## Chunk 2: NotificationProvider & Custom Endpoints
### Task 4: Create the NotificationProvider
- [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user:
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<Notification>
*/
final readonly class NotificationProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object
{
$user = $this->security->getUser();
return $this->notificationRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
30,
);
}
}
```
- [ ] **Commit:**
```bash
git add src/State/NotificationProvider.php
git commit -m "feat(notification) : add NotificationProvider filtered by current user"
```
### Task 5: Create the UnreadCountController
- [ ] **Create `src/Controller/NotificationUnreadCountController.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $count]);
}
}
```
### Task 6: Create the MarkAllReadController
- [ ] **Create `src/Controller/MarkAllReadController.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
return new Response(null, Response::HTTP_NO_CONTENT);
}
}
```
- [ ] **Commit:**
```bash
git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php
git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers"
```
---
## Chunk 3: NotificationService & Processor Integration
### Task 7: Create NotificationService
- [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ClientTicket;
use App\Entity\Notification;
use App\Entity\User;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
) {}
/**
* Notify all ROLE_ADMIN users that a new ticket was created.
*/
public function createForTicketCreated(ClientTicket $ticket): void
{
$admins = $this->userRepository->findByRole('ROLE_ADMIN');
$number = sprintf('CT-%03d', $ticket->getNumber());
$projectName = $ticket->getProject()?->getName() ?? '';
foreach ($admins as $admin) {
$notification = new Notification();
$notification->setUser($admin);
$notification->setType('ticket_created');
$notification->setTitle('Nouveau ticket client ' . $number);
$notification->setMessage($ticket->getTitle() . ' — ' . $projectName);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
/**
* Notify the ticket submitter that the status has changed.
*/
public function createForStatusChange(ClientTicket $ticket): void
{
$submittedBy = $ticket->getSubmittedBy();
if (null === $submittedBy) {
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusLabel = $ticket->getStatus();
$message = 'Nouveau statut : ' . $statusLabel;
if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) {
$message .= ' — ' . $ticket->getStatusComment();
}
$notification = new Notification();
$notification->setUser($submittedBy);
$notification->setType('ticket_status_changed');
$notification->setTitle('Ticket ' . $number . ' mis à jour');
$notification->setMessage($message);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
}
```
### Task 8: Add findByRole method to UserRepository
- [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`:
```php
/**
* @return User[]
*/
public function findByRole(string $role): array
{
return $this->createQueryBuilder('u')
->where('u.roles LIKE :role')
->setParameter('role', '%"' . $role . '"%')
->getQuery()
->getResult();
}
```
- [ ] **Commit:**
```bash
git add src/Service/NotificationService.php src/Repository/UserRepository.php
git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole"
```
### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST)
- [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted:
Add to constructor parameters:
```php
private readonly NotificationService $notificationService,
```
Add import at the top:
```php
use App\Service\NotificationService;
```
After `$this->entityManager->flush();` in the POST handling block, add:
```php
$this->notificationService->createForTicketCreated($data);
```
### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH)
- [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted:
Add to constructor parameters:
```php
private readonly NotificationService $notificationService,
```
Add import at the top:
```php
use App\Service\NotificationService;
```
After `$this->entityManager->flush();` in the PATCH handling block, add:
```php
$this->notificationService->createForStatusChange($data);
```
- [ ] **Commit:**
```bash
git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php
git commit -m "feat(notification) : hook NotificationService into ticket processors"
```
---
## Chunk 4: Frontend — DTO & Service
### Task 11: Create the Notification DTO
- [ ] **Create `frontend/services/dto/notification.ts`**:
```typescript
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
export type Notification = {
'@id'?: string
id: number
user: string
type: NotificationType
title: string
message: string
relatedTicket: string | null
isRead: boolean
createdAt: string
}
```
### Task 12: Create the notifications service
- [ ] **Create `frontend/services/notifications.ts`**:
```typescript
import type { Notification } from './dto/notification'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useNotificationService() {
const api = useApi()
async function getAll(): Promise<Notification[]> {
const data = await api.get<HydraCollection<Notification>>('/notifications')
return extractHydraMembers(data)
}
async function markAsRead(id: number): Promise<void> {
await api.patch(`/notifications/${id}`, { isRead: true }, {
toast: false,
})
}
async function markAllAsRead(): Promise<void> {
await api.post('/notifications/mark-all-read', {}, {
toast: false,
})
}
async function getUnreadCount(): Promise<number> {
const data = await api.get<{ count: number }>('/notifications/unread-count', {}, {
toast: false,
})
return data.count
}
return { getAll, markAsRead, markAllAsRead, getUnreadCount }
}
```
- [ ] **Commit:**
```bash
git add frontend/services/dto/notification.ts frontend/services/notifications.ts
git commit -m "feat(frontend) : add notification DTO and service"
```
---
## Chunk 5: Frontend — Composable & Component
### Task 13: Create the useNotifications composable
- [ ] **Create `frontend/composables/useNotifications.ts`**:
```typescript
import type { Notification } from '~/services/dto/notification'
import { useNotificationService } from '~/services/notifications'
const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes
export function useNotifications() {
const unreadCount = useState<number>('notification-unread-count', () => 0)
const notifications = useState<Notification[]>('notification-list', () => [])
const isLoading = useState<boolean>('notification-loading', () => false)
const service = useNotificationService()
let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchUnreadCount(): Promise<void> {
try {
unreadCount.value = await service.getUnreadCount()
} catch {
// Silently ignore polling errors
}
}
async function fetchNotifications(): Promise<void> {
isLoading.value = true
try {
notifications.value = await service.getAll()
} finally {
isLoading.value = false
}
}
async function markAsRead(id: number): Promise<void> {
await service.markAsRead(id)
const notif = notifications.value.find(n => n.id === id)
if (notif && !notif.isRead) {
notif.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
async function markAllAsRead(): Promise<void> {
await service.markAllAsRead()
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
}
function startPolling(): void {
fetchUnreadCount()
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
unreadCount,
notifications,
isLoading,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
}
}
```
- [ ] **Commit:**
```bash
git add frontend/composables/useNotifications.ts
git commit -m "feat(frontend) : add useNotifications composable with polling"
```
### Task 14: Create the NotificationBell component
- [ ] **Create `frontend/components/notification/NotificationBell.vue`**:
```vue
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<Transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<h3 class="text-sm font-semibold text-neutral-800">
{{ $t('notification.title') }}
</h3>
<button
v-if="unreadCount > 0"
type="button"
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
@click="handleMarkAllRead"
>
{{ $t('notification.markAllRead') }}
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
</div>
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
{{ $t('notification.empty') }}
</div>
<template v-else>
<button
v-for="notif in notifications"
:key="notif.id"
type="button"
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
:class="{ 'bg-primary-50': !notif.isRead }"
@click="handleClick(notif)"
>
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-neutral-800 truncate">
{{ notif.title }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ notif.message }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ formatRelativeDate(notif.createdAt) }}
</p>
</div>
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Notification } from '~/services/dto/notification'
import { useNotifications } from '~/composables/useNotifications'
const {
unreadCount,
notifications,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
} = useNotifications()
const bellRef = ref<HTMLElement>()
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
fetchNotifications()
}
}
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
if (notif.relatedTicket) {
const ticketId = notif.relatedTicket.split('/').pop()
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
}
async function handleMarkAllRead() {
await markAllAsRead()
}
const { t } = useI18n()
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMin / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMin < 1) return t('notification.timeAgo.now')
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
// Close dropdown when clicking outside
function onClickOutside(event: MouseEvent) {
if (!bellRef.value?.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
startPolling()
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
```
- [ ] **Commit:**
```bash
git add frontend/components/notification/NotificationBell.vue
git commit -m "feat(frontend) : add NotificationBell component with dropdown"
```
---
## Chunk 6: Layout Integration & i18n
### Task 15: Integrate NotificationBell in AppTopNav
- [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `<div class="ml-auto flex gap-4 ...">` block (line 10):
Replace:
```vue
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
<div class="group relative flex gap-2 sm:gap-4">
```
With:
```vue
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
```
No imports needed — Nuxt auto-imports components from `frontend/components/`.
- [ ] **Commit:**
```bash
git add frontend/components/ui/AppTopNav.vue
git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar"
```
### Task 16: Add i18n translations
- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys):
```json
"notification": {
"title": "Notifications",
"markAllRead": "Tout marquer comme lu",
"empty": "Aucune notification",
"ticketCreated": "Nouveau ticket client {number}",
"ticketStatusChanged": "Ticket {number} mis à jour",
"timeAgo": {
"now": "À l'instant",
"minutes": "Il y a {n} min",
"hours": "Il y a {n}h",
"days": "Il y a {n}j"
}
}
```
- [ ] **Commit:**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(i18n) : add notification translations in French"
```
---
## Chunk 7: Verification & Cleanup
### Task 17: Test backend endpoints manually
- [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`):
1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}`
2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination)
3. `GET /api/notifications/unread-count` — should return `{"count": 0}`
4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin
5. `GET /api/notifications` — should now list the `ticket_created` notification
6. `GET /api/notifications/unread-count` — should return `{"count": 1}`
7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read
8. `POST /api/notifications/mark-all-read` — should return 204
### Task 18: Test frontend notification bell
- [ ] **Start dev server** (`make dev-nuxt`) and verify:
1. The bell icon appears in the top navigation bar, to the left of the user avatar
2. Badge shows unread count (or is hidden when 0)
3. Clicking the bell opens a dropdown with notification list
4. Clicking a notification marks it as read and navigates appropriately
5. "Tout marquer comme lu" button works
6. Polling updates the badge every 2 minutes
- [ ] **Final commit (if any fixes needed):**
```bash
git add -A
git commit -m "fix(notification) : polish notification bell and fix edge cases"
```

View File

@@ -0,0 +1,385 @@
# Date Filter Component Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`, enabling filtering by single day or date range.
**Architecture:** A wrapper component `DateFilter.vue` encapsulates `VueDatePicker` with project-consistent styling. It integrates into the existing filter bar on the time-tracking page. Filtering is client-side, matching the existing project/tag filter pattern.
**Tech Stack:** Vue 3, @vuepic/vue-datepicker, Tailwind CSS, @nuxtjs/i18n
---
## Chunk 1: Setup and Component
### Task 1: Install @vuepic/vue-datepicker and configure Nuxt
**Files:**
- Modify: `frontend/package.json`
- Modify: `frontend/nuxt.config.ts:1-66`
- [ ] **Step 1: Install the package**
Run inside the PHP container (where Node is available):
```bash
cd /home/r-dev/Lesstime/frontend && npm install @vuepic/vue-datepicker
```
- [ ] **Step 2: Add transpile config to nuxt.config.ts**
In `frontend/nuxt.config.ts`, add `build.transpile` after the `typescript` block:
```typescript
export default defineNuxtConfig({
// ... existing config ...
typescript: {
strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
})
```
- [ ] **Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/nuxt.config.ts
git commit -m "feat(frontend) : add @vuepic/vue-datepicker dependency"
```
---
### Task 2: Add i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json:167-170`
- [ ] **Step 1: Add date filter translations to fr.json**
In `frontend/i18n/locales/fr.json`, add keys inside the existing `"common"` block:
```json
"common": {
"cancel": "Annuler",
"loading": "Chargement...",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"clear": "Effacer"
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add date filter i18n translations"
```
---
### Task 3: Create DateFilter.vue component
**Files:**
- Create: `frontend/components/ui/DateFilter.vue`
- [ ] **Step 1: Create the component**
Create `frontend/components/ui/DateFilter.vue`:
```vue
<template>
<div class="date-filter">
<VueDatePicker
v-model="internalValue"
:range="isRange"
:enable-time-picker="false"
:text-input="textInputConfig"
:locale="'fr'"
:format="formatDate"
:preview-format="formatDate"
auto-apply
:multi-calendars="false"
position="left"
@update:model-value="onUpdate"
@cleared="onClear"
>
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
<div class="relative">
<input
:value="value"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="placeholder || t('common.dateFilter')"
readonly
@click="openMenu"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
/>
<button
v-if="value"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</template>
<template #action-buttons>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import VueDatePicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const isRange = ref(false)
const internalValue = ref<Date | Date[] | null>(null)
const firstClick = ref<Date | null>(null)
const textInputConfig = {
enterSubmit: true,
tabSubmit: true,
format: 'dd/MM/yyyy',
rangeSeparator: ' - ',
}
function formatDate(date: Date | Date[]): string {
if (Array.isArray(date)) {
return date.map(d => formatSingleDate(d)).join(' - ')
}
return formatSingleDate(date)
}
function formatSingleDate(d: Date): string {
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function onUpdate(value: Date | Date[] | null) {
if (value === null) {
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
return
}
if (Array.isArray(value) && value.length === 2) {
emit('update:modelValue', [value[0], value[1]])
return
}
if (value instanceof Date) {
if (firstClick.value === null) {
// First click — select single day, store for potential range
firstClick.value = value
emit('update:modelValue', value)
// Enable range mode for next click
nextTick(() => {
isRange.value = true
})
}
}
}
function onClear() {
internalValue.value = null
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
isRange.value = false
firstClick.value = null
internalValue.value = today
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
isRange.value = true
firstClick.value = null
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
// Sync external modelValue to internal state
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
firstClick.value = null
isRange.value = false
} else if (Array.isArray(val)) {
isRange.value = true
internalValue.value = [...val]
} else {
isRange.value = false
internalValue.value = val
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>
```
- [ ] **Step 2: Verify the component renders**
Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur.
- [ ] **Step 3: Commit**
```bash
git add frontend/components/ui/DateFilter.vue
git commit -m "feat(frontend) : create DateFilter reusable component"
```
---
## Chunk 2: Integration
### Task 4: Integrate DateFilter into time-tracking page
**Files:**
- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar)
- Modify: `frontend/pages/time-tracking.vue:138` (add ref)
- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed)
- [ ] **Step 1: Add the date filter ref**
In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add:
```typescript
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
```
- [ ] **Step 2: Add DateFilter to the template filter bar**
In the filter bar `<div>` (line 15), after the tag MalioSelect block (after line 72), add:
```vue
<DateFilter
v-model="selectedDateFilter"
/>
```
- [ ] **Step 3: Add date filtering to filteredEntries computed**
In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering:
```typescript
const filteredEntries = computed(() => {
let result = entries.value
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result
})
```
- [ ] **Step 4: Verify manually**
Run `make dev-nuxt`, navigate to time-tracking page:
1. Verify DateFilter appears in the filter bar
2. Click a single day — entries filter to that day
3. Click a second day — entries filter to the range
4. Click "Aujourd'hui" — filters to today
5. Click "Cette semaine" — filters to current week
6. Clear the filter — all entries show again
- [ ] **Step 5: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat(frontend) : integrate date filter into time-tracking page"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
# Date Filter Component - Design Spec
## Summary
Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown.
## Behavior
- **Single click** on a day = select that day
- **Second click** on another day = select range between the two dates
- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`)
- **Calendar dropdown**: opens on input click/focus
- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar
- **No time picker**: filter by day granularity only
- **Format**: `dd/MM/yyyy` (French locale)
## Component: `DateFilter.vue`
Location: `frontend/components/ui/DateFilter.vue`
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range |
| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder |
### Emits
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed |
### Implementation
- Wraps `VueDatePicker` with project-consistent styling
- Uses `#dp-input` slot for custom input matching MalioSelect style
- Configures `range` mode with `multi-calendars: false`
- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '`
- Disables time picker (`enable-time-picker: false`)
- Applies project primary color (`#222783`) via CSS overrides
- Responsive width: `!w-44 sm:!w-52`
## Integration: Time Tracking Page
### Filter bar addition
Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters.
### Filtering logic
- Client-side filtering (same pattern as project and tag filters)
- When a single date is selected: show only entries matching that day
- When a range is selected: show entries within the range (inclusive)
- When null: show all entries (no date filter)
## Files Impacted
| File | Action | Description |
|------|--------|-------------|
| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper |
| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` |
| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering |
| `frontend/i18n/locales/fr.json` | Modify | Add French translations |
| `frontend/i18n/locales/en.json` | Modify | Add English translations |
| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency |
## i18n Keys
```json
{
"common": {
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine"
}
}
```
## Style
- Input height and borders match MalioSelect components
- Text size: `text-sm`
- Selected date highlight: project primary color `#222783`
- Calendar dropdown: subtle shadow, rounded corners matching project style
- Override default vue-datepicker CSS variables to match project theme

View File

@@ -0,0 +1,495 @@
# MCP Server for Lesstime — Design Spec
**Date**: 2026-03-15
**Status**: Draft
**Scope**: Expose projects, tasks, and time tracking via MCP for AI clients (Claude Code local first)
## Context
Lesstime is a project management app (Symfony 8 + API Platform 4). We want AI assistants to interact with projects, tasks, and time entries via the Model Context Protocol (MCP).
Both transports are implemented together:
- **STDIO**: Claude Code on the same machine (local dev, `php bin/console mcp:server`)
- **HTTP**: Claude Code or any MCP client on the LAN (`http://<server-ip>:8082/_mcp`), secured by API token
Future: Cloudflare Tunnel for internet-facing access (Claude Web, ChatGPT, Codex).
## Technology Choice
**`symfony/mcp-bundle`** — the official Symfony MCP bundle, maintained by Symfony + PHP Foundation + Anthropic. Uses PHP attributes (`#[McpTool]`) for auto-discovery.
## Architecture
### File Structure
```
src/Mcp/
├── Tool/
│ ├── Project/
│ │ ├── ListProjectsTool.php
│ │ ├── GetProjectTool.php
│ │ ├── CreateProjectTool.php
│ │ └── UpdateProjectTool.php
│ ├── Task/
│ │ ├── ListTasksTool.php
│ │ ├── GetTaskTool.php
│ │ ├── CreateTaskTool.php
│ │ ├── UpdateTaskTool.php
│ │ └── DeleteTaskTool.php
│ ├── TaskMeta/
│ │ ├── ListStatusesTool.php
│ │ ├── ListPrioritiesTool.php
│ │ ├── ListEffortsTool.php
│ │ ├── ListTagsTool.php
│ │ ├── ListGroupsTool.php
│ │ ├── CreateGroupTool.php
│ │ └── UpdateGroupTool.php
│ ├── TimeEntry/
│ │ ├── ListTimeEntriesTool.php
│ │ ├── CreateTimeEntryTool.php
│ │ ├── UpdateTimeEntryTool.php
│ │ └── DeleteTimeEntryTool.php
│ └── Reference/
│ ├── ListUsersTool.php
│ └── ListClientsTool.php
```
### Configuration
```yaml
# config/packages/mcp.yaml
mcp:
app: 'lesstime'
version: '1.0.0'
description: 'Lesstime project management — projects, tasks, time tracking'
instructions: |
This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
Groups are PER-PROJECT (each group belongs to one project).
Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks.
Use list-users and list-clients to discover valid user and client IDs.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600
```
### Nginx Configuration
Add a location block to pass `/_mcp` requests to Symfony (same pattern as `/api`):
```nginx
location /_mcp {
try_files $uri /index.php$is_args$args;
}
```
### Claude Code Configuration
**Option A — Local (STDIO, same machine):**
```json
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"],
"cwd": "/home/r-dev/Lesstime"
}
}
}
```
**Option B — Network (HTTP, another machine on LAN):**
```json
{
"mcpServers": {
"lesstime": {
"type": "url",
"url": "http://192.168.x.x:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
}
}
}
```
### Security Model
**STDIO transport**: No authentication. The console command runs locally with full privileges (equivalent to ROLE_ADMIN). Only the local developer has access.
**HTTP transport**: Secured by API token. A new `apiToken` field on the `User` entity stores a unique token per user. A custom Symfony authenticator (`ApiTokenAuthenticator`) checks the `Authorization: Bearer <token>` header on `/_mcp` requests and authenticates as the corresponding user.
#### API Token Implementation
1. **Entity change**: Add `apiToken` (string, unique, nullable) to `User` + Doctrine migration
2. **Authenticator**: `src/Security/ApiTokenAuthenticator.php` — a Symfony custom authenticator that:
- Extracts the token from the `Authorization` header
- Looks up the user by `apiToken`
- Returns 401 if token missing/invalid
3. **Firewall**: New firewall entry in `config/packages/security.yaml` for `/_mcp` path, before the main `api` firewall
4. **Token generation**: A console command `app:generate-api-token <username>` to generate/regenerate tokens
5. **Fixtures**: Add an API token to the admin fixture user for dev/testing
## Tools Specification
### Reference Tools (ID Discovery)
#### `list-users`
- **Description**: List all users (needed to resolve assignee/user IDs)
- **Returns**: Array of `{ id, username }`
- **Implementation**: `UserRepository::findBy([], ['username' => 'ASC'])`
#### `list-clients`
- **Description**: List all clients (needed to resolve client IDs for projects)
- **Returns**: Array of `{ id, name, email }`
- **Implementation**: `ClientRepository::findBy([], ['name' => 'ASC'])`
### Project Tools
#### `list-projects`
- **Description**: List all projects with optional archive filter
- **Parameters**: `archived` (bool, optional, default: false)
- **Returns**: Array of `{ id, code, name, description, color, client: { id, name } | null, archived }`
- **Implementation**: `ProjectRepository::findBy(['archived' => $archived], ['name' => 'ASC'])`
#### `get-project`
- **Description**: Get project details with task count summary per status
- **Parameters**: `id` (int, required)
- **Returns**: `{ id, code, name, description, color, client, archived, taskSummary: { statusLabel: count, ... }, totalTasks }`
- **Implementation**: `ProjectRepository::find($id)` + DQL count query grouped by status
#### `create-project`
- **Description**: Create a new project
- **Parameters**: `name` (string, required), `code` (string, required, 2-10 uppercase letters), `description` (string, optional), `color` (string, optional), `clientId` (int, optional)
- **Returns**: Created project object
- **Implementation**: Create `Project` entity, persist via `EntityManager`
#### `update-project`
- **Description**: Update an existing project (partial update)
- **Parameters**:
- `id` (int, required)
- `name` (string, optional)
- `code` (string, optional)
- `description` (string, optional)
- `color` (string, optional)
- `clientId` (int, optional)
- `archived` (bool, optional)
- **Returns**: Updated project object
- **Implementation**: Find project, apply changes, flush
### Task Tools
#### `list-tasks`
- **Description**: List tasks with filters. Returns max 100 results, use filters to narrow down.
- **Parameters**:
- `projectId` (int, optional) — filter by project
- `statusId` (int, optional) — filter by status
- `assigneeId` (int, optional) — filter by assignee
- `priorityId` (int, optional) — filter by priority
- `groupId` (int, optional) — filter by group
- `tagIds` (int[], optional) — filter by tags
- `archived` (bool, optional, default: false)
- `limit` (int, optional, default: 100, max: 200)
- **Returns**: Array of `{ id, number, title, status: { id, label, color }, priority: { id, label, color } | null, assignee: { id, username } | null, effort: { id, label } | null, group: { id, title } | null, project: { id, code, name }, tags: [{ id, label }], archived }`
- **Implementation**: `TaskRepository` with QueryBuilder, conditional filters, and `setMaxResults($limit)`. Joins must include all relations: status, priority, assignee, project, effort, group, tags.
#### `get-task`
- **Description**: Get full task details
- **Parameters**: `id` (int, required)
- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size, createdAt, uploadedBy: { id, username } }], archived }`
- **Implementation**: `TaskRepository::find($id)` with eager loading
#### `create-task`
- **Description**: Create a new task (number auto-generated per project)
- **Parameters**:
- `projectId` (int, required)
- `title` (string, required)
- `description` (string, optional)
- `statusId` (int, optional)
- `priorityId` (int, optional)
- `effortId` (int, optional)
- `assigneeId` (int, optional)
- `groupId` (int, optional)
- `tagIds` (int[], optional)
- **Returns**: Created task with auto-generated number
- **Implementation**: Create `Task` entity, reuse `TaskRepository::findMaxNumberByProject()` for number generation (same logic as `TaskNumberProcessor`), set relations, persist
#### `update-task`
- **Description**: Update an existing task (partial update, only provided fields are changed)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `description` (string, optional)
- `statusId` (int, optional)
- `priorityId` (int, optional)
- `effortId` (int, optional)
- `assigneeId` (int, optional)
- `groupId` (int, optional)
- `tagIds` (int[], optional)
- `archived` (bool, optional)
- **Returns**: Updated task object
- **Implementation**: Find task, apply changes, flush
#### `delete-task`
- **Description**: Delete a task permanently
- **Parameters**: `id` (int, required)
- **Returns**: `{ success: true, message: "Task PROJECT-123 deleted" }`
- **Implementation**: `EntityManager::remove()` + flush (cascade deletes documents)
### TaskMeta Tools
Statuses, priorities, efforts, and tags are **global** (shared across all projects, read-only via MCP). Groups are **per-project** (read/create/update).
#### `list-statuses`
- **Description**: List all task statuses (needed to create/update tasks)
- **Returns**: Array of `{ id, label, color, position, isFinal }`
- **Implementation**: `TaskStatusRepository::findBy([], ['position' => 'ASC'])`
#### `list-priorities`
- **Description**: List all task priorities
- **Returns**: Array of `{ id, label, color }`
- **Implementation**: `TaskPriorityRepository::findBy([], ['label' => 'ASC'])`
#### `list-efforts`
- **Description**: List all task effort levels
- **Returns**: Array of `{ id, label }`
- **Implementation**: `TaskEffortRepository::findBy([], ['label' => 'ASC'])`
#### `list-tags`
- **Description**: List all task tags
- **Returns**: Array of `{ id, label, color }`
- **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])`
#### `list-groups`
- **Description**: List task groups, optionally filtered by project. Groups are per-project.
- **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false)
- **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }`
- **Implementation**: `TaskGroupRepository` with optional project filter
#### `create-group`
- **Description**: Create a new task group for a project
- **Parameters**:
- `projectId` (int, required)
- `title` (string, required)
- `description` (string, optional)
- `color` (string, optional, default: #222783)
- **Returns**: Created group object
- **Implementation**: Create `TaskGroup` entity, set project relation, persist
#### `update-group`
- **Description**: Update an existing task group (partial update)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `description` (string, optional)
- `color` (string, optional)
- `archived` (bool, optional)
- **Returns**: Updated group object
- **Implementation**: Find group, apply changes, flush
### TimeEntry Tools
#### `list-time-entries`
- **Description**: List time entries with filters
- **Parameters**:
- `userId` (int, optional)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `startDate` (string, optional, format: YYYY-MM-DD)
- `endDate` (string, optional, format: YYYY-MM-DD)
- `limit` (int, optional, default: 100, max: 200)
- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration, user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }`
- **Note**: `duration` is computed from `stoppedAt - startedAt` in minutes. Returns `null` for active timers (stoppedAt is null).
- **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt`
#### `create-time-entry`
- **Description**: Create a time entry
- **Parameters**:
- `userId` (int, required)
- `startedAt` (string, required, ISO 8601)
- `title` (string, optional)
- `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `tagIds` (int[], optional)
- `description` (string, optional)
- **Returns**: Created time entry
- **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null.
#### `update-time-entry`
- **Description**: Update a time entry (e.g., stop a running timer, correct start time)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `startedAt` (string, optional, ISO 8601)
- `stoppedAt` (string, optional, ISO 8601)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `tagIds` (int[], optional)
- `description` (string, optional)
- **Returns**: Updated time entry
- **Note**: `userId` is intentionally not updatable via MCP. Reassigning time entries to another user should be done through the app UI.
- **Implementation**: Find entry, apply changes, flush
#### `delete-time-entry`
- **Description**: Delete a time entry
- **Parameters**: `id` (int, required)
- **Returns**: `{ success: true, message: "Time entry deleted" }`
- **Implementation**: `EntityManager::remove()` + flush
## Tool Return Format
All tools return JSON strings. For consistency:
- **List tools**: Return a JSON array of objects
- **Get/Create/Update tools**: Return a single JSON object
- **Delete tools**: Return `{ success: true, message: "..." }`
- **Errors**: Throw exceptions (the MCP bundle handles error responses)
- **Duration**: Computed field (minutes), `null` for active timers
Example tool implementation pattern:
```php
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
class ListTasksTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {
}
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state')]
public function __invoke(
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
bool $archived = false,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t')
->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g')
->leftJoin('t.tags', 'tg')->addSelect('tg')
->where('t.archived = :archived')
->setParameter('archived', $archived)
->orderBy('t.id', 'DESC')
->setMaxResults($limit);
if ($projectId !== null) {
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
}
if ($statusId !== null) {
$qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId);
}
if ($assigneeId !== null) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
}
if ($priorityId !== null) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
}
if ($groupId !== null) {
$qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId);
}
$tasks = $qb->getQuery()->getResult();
// Filter by tags in PHP (ManyToMany not easily filterable in DQL)
if ($tagIds !== null) {
$tasks = array_filter($tasks, function ($task) use ($tagIds) {
$taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray();
return !empty(array_intersect($tagIds, $taskTagIds));
});
}
return json_encode(array_map(fn($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
], array_values($tasks)));
}
}
```
## Installation Steps
1. `composer require symfony/mcp-bundle` (inside Docker container)
2. Create `config/packages/mcp.yaml` with STDIO + HTTP transports
3. Add MCP route: `config/routes/mcp.yaml`
4. Add Nginx location block for `/_mcp`
5. Add `apiToken` field to `User` entity + migration
6. Create `ApiTokenAuthenticator` + security firewall for `/_mcp`
7. Create `app:generate-api-token` console command
8. Update fixtures with API token for admin user
9. Create tool classes in `src/Mcp/Tool/`
10. Test STDIO: `php bin/console mcp:server`
11. Test HTTP: `curl -H "Authorization: Bearer <token>" http://localhost:8082/_mcp`
12. Configure Claude Code settings (STDIO local or HTTP network)
## Future
When ready for internet-facing access:
1. Set up Cloudflare Tunnel for external access
2. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token

View File

@@ -0,0 +1,379 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
</div>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="filterProjectId"
:options="projectOptions"
label="Projet"
:empty-option-label="$t('clientTicket.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
<option value="new">{{ $t('clientTicket.status.new') }}</option>
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
<option value="done">{{ $t('clientTicket.status.done') }}</option>
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
</select>
</div>
</div>
<!-- Ticket list -->
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
<th class="px-3 py-3">#</th>
<th class="px-3 py-3">Type</th>
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
<th class="px-3 py-3">Statut</th>
<th class="px-3 py-3">Projet</th>
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
<th class="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="ticket in filteredTickets"
:key="ticket.id"
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
@click="openDetail(ticket)"
>
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
<td class="px-3 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
</td>
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
<td class="px-3 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</td>
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
@click.stop="openDeleteConfirm(ticket)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="status-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="statusModalOpen = false"
/>
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
CT-{{ String(statusTarget.number).padStart(3, '0') }} {{ statusTarget.title }}
</p>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
<select
v-model="newStatus"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option :value="null" disabled></option>
<option
v-for="s in availableStatusTransitions"
:key="s.value"
:value="s.value"
>
{{ s.label }}
</option>
</select>
</div>
<div v-if="newStatus === 'rejected'" class="mt-4">
<MalioInputTextArea
v-model="statusComment"
:label="$t('clientTicket.statusComment')"
:size="3"
/>
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
{{ $t('clientTicket.rejectionRequired') }}
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Delete confirm modal -->
<Teleport v-if="deleteModalOpen" to="body">
<Transition name="status-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="deleteModalOpen = false"
/>
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="deleteModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isDeleting"
@click="confirmDelete"
>
Supprimer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Ticket detail modal (read-only) -->
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="detailTicket"
/>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const userService = useUserService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
// Filters
const filterProjectId = ref<number | null>(null)
const filterStatus = ref<string | null>(null)
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const filteredTickets = computed(() => {
let result = tickets.value
if (filterProjectId.value) {
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
}
if (filterStatus.value) {
result = result.filter(t => t.status === filterStatus.value)
}
return result
})
// Status change modal
const statusModalOpen = ref(false)
const statusTarget = ref<ClientTicket | null>(null)
const newStatus = ref<string | null>(null)
const statusComment = ref('')
const rejectionError = ref(false)
const isUpdatingStatus = ref(false)
// Delete modal
const deleteModalOpen = ref(false)
const deleteTarget = ref<ClientTicket | null>(null)
const isDeleting = ref(false)
// Detail modal
const detailOpen = ref(false)
const detailTicket = ref<ClientTicket | null>(null)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
// Filter out forbidden transitions
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
})
function getProjectName(iri: string): string {
const match = iri.match(/\/api\/projects\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
return projects.value.find(p => p.id === id)?.name ?? ''
}
function getSubmitterName(iri: string | null): string {
if (!iri) return '-'
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
return users.value.find(u => u.id === id)?.username ?? ''
}
function openDetail(ticket: ClientTicket) {
detailTicket.value = ticket
detailOpen.value = true
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.value = true
}
function openDeleteConfirm(ticket: ClientTicket) {
deleteTarget.value = ticket
deleteModalOpen.value = true
}
async function confirmStatusChange() {
if (!statusTarget.value || !newStatus.value) return
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
rejectionError.value = true
return
}
isUpdatingStatus.value = true
try {
await clientTicketService.updateStatus(statusTarget.value.id, {
status: newStatus.value as ClientTicketStatus,
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
})
statusModalOpen.value = false
await loadTickets()
} finally {
isUpdatingStatus.value = false
}
}
async function confirmDelete() {
if (!deleteTarget.value) return
isDeleting.value = true
try {
await clientTicketService.remove(deleteTarget.value.id)
deleteModalOpen.value = false
await loadTickets()
} finally {
isDeleting.value = false
}
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll()
}
async function loadData() {
isLoading.value = true
try {
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
clientTicketService.getAll(),
projectService.getAll(),
userService.getAll(),
])
tickets.value = ticketsResult
projects.value = projectsResult
users.value = usersResult
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.status-modal-enter-active,
.status-modal-leave-active {
transition: opacity 0.2s ease;
}
.status-modal-enter-from,
.status-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="ticket-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
v-if="ticket"
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
>
CT-{{ String(ticket.number).padStart(3, '0') }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ $t('portal.ticketDetail') }}
</h2>
</div>
<div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<button
v-if="canEdit && !isEditing"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
@click="startEdit"
>
<Icon name="mdi:pencil-outline" size="16" />
{{ $t('common.edit') }}
</button>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
</div>
<!-- Body -->
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Edit mode -->
<template v-if="isEditing">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.title') }}
</label>
<input
v-model="editForm.title"
type="text"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.description') }}
</label>
<textarea
v-model="editForm.description"
rows="5"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div v-if="ticket.type === 'bug'" class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.url') }}
</label>
<input
v-model="editForm.url"
type="url"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="cancelEdit"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSaving"
@click="saveEdit"
>
{{ $t('common.save') }}
</button>
</div>
</template>
<!-- View mode -->
<template v-else>
<!-- Title -->
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
<!-- Badges -->
<div class="mt-3 flex items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<!-- Description -->
<div class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
</div>
<!-- URL (if bug) -->
<div v-if="ticket.url" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
<a
:href="ticket.url"
target="_blank"
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<!-- Status comment -->
<div v-if="ticket.statusComment" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
</div>
<!-- Documents -->
<TaskDocumentList
v-if="localDocuments.length"
:documents="localDocuments"
:is-admin="canEdit"
@preview="openPreview"
@delete="handleDeleteDocument"
/>
<!-- Document preview -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- Upload zone -->
<TaskDocumentUpload
v-if="ticket"
:client-ticket-id="ticket.id"
@uploaded="refreshDocuments"
/>
<!-- Date -->
<p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
modelValue: boolean
ticket: ClientTicket | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isEditing.value = false
isOpen.value = false
}
const auth = useAuthStore()
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
// Edit mode
const isEditing = ref(false)
const isSaving = ref(false)
const editForm = reactive({
title: '',
description: '',
url: '',
})
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const canEdit = computed(() => {
if (!props.ticket) return false
if (isAdmin.value) return true
const status = props.ticket.status
if (status === 'done' || status === 'rejected') return false
const userId = auth.user?.id
if (!userId) return false
const submittedByIri = props.ticket.submittedBy
if (!submittedByIri) return false
return submittedByIri === `/api/users/${userId}`
})
function startEdit() {
if (!props.ticket) return
editForm.title = props.ticket.title
editForm.description = props.ticket.description
editForm.url = props.ticket.url ?? ''
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
}
async function saveEdit() {
if (!props.ticket) return
isSaving.value = true
try {
const data: Record<string, unknown> = {
title: editForm.title,
description: editForm.description,
}
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as any)
isEditing.value = false
emit('refresh')
} finally {
isSaving.value = false
}
}
// Reset edit mode when ticket changes
watch(() => props.ticket?.id, () => {
isEditing.value = false
})
async function handleDeleteDocument(doc: TaskDocument) {
await removeDocument(doc.id)
await refreshDocuments()
}
async function refreshDocuments() {
if (!props.ticket) return
localDocuments.value = await getByTicket(props.ticket.id)
}
// Document list (local copy to allow refresh)
const localDocuments = ref<TaskDocument[]>([])
watch(() => props.ticket?.documents, (docs) => {
localDocuments.value = docs ? [...docs] : []
}, { immediate: true })
// Document preview
const previewDoc = ref<TaskDocument | null>(null)
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
previewDoc.value = doc
}
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = localDocuments.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = localDocuments.value[previewIndex.value + 1]
}
}
</script>
<style scoped>
.ticket-modal-enter-active,
.ticket-modal-leave-active {
transition: opacity 0.2s ease;
}
.ticket-modal-enter-active > div:last-child,
.ticket-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.ticket-modal-enter-from,
.ticket-modal-leave-to {
opacity: 0;
}
.ticket-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.ticket-modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,338 @@
<template>
<div>
<!-- Trigger button -->
<button
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
@click="open"
>
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span
v-if="totalCount > 0"
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
>
{{ totalCount }}
</span>
</button>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
<Transition name="ct-panel" appear>
<div class="fixed inset-0 z-50 flex justify-end">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Slide panel -->
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
<option value="new">{{ $t('clientTicket.status.new') }}</option>
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
<option value="done">{{ $t('clientTicket.status.done') }}</option>
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
</select>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="space-y-2">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="rounded-lg border border-neutral-200 bg-white"
>
<!-- Ticket row -->
<div
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
@click="toggleExpand(ticket.id)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="16" />
</button>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18"
class="text-neutral-400"
/>
</div>
</div>
<!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
<div v-if="ticket.url" class="mt-2">
<a
:href="ticket.url"
target="_blank"
class="text-xs text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
{{ ticket.statusComment }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="ct-modal" appear>
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="statusModalOpen = false"
/>
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
CT-{{ String(statusTarget.number).padStart(3, '0') }} {{ statusTarget.title }}
</p>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
<select
v-model="newStatus"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null" disabled></option>
<option
v-for="s in availableStatusTransitions"
:key="s.value"
:value="s.value"
>
{{ s.label }}
</option>
</select>
</div>
<div v-if="newStatus === 'rejected'" class="mt-4">
<MalioInputTextArea
v-model="statusComment"
:label="$t('clientTicket.statusComment')"
:size="3"
/>
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
{{ $t('clientTicket.rejectionRequired') }}
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
projectId: number
projectName: string
}>()
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
const tickets = ref<ClientTicket[]>([])
const filterStatus = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const totalCount = computed(() =>
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
)
const filteredTickets = computed(() => {
if (!filterStatus.value) return tickets.value
return tickets.value.filter(t => t.status === filterStatus.value)
})
// Status change
const statusModalOpen = ref(false)
const statusTarget = ref<ClientTicket | null>(null)
const newStatus = ref<string | null>(null)
const statusComment = ref('')
const rejectionError = ref(false)
const isUpdatingStatus = ref(false)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
})
async function loadTickets() {
isLoading.value = true
try {
tickets.value = await clientTicketService.getAll({ project: props.projectId })
} finally {
isLoading.value = false
}
}
function open() {
isOpen.value = true
loadTickets()
}
function close() {
isOpen.value = false
expandedId.value = null
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.value = true
}
async function confirmStatusChange() {
if (!statusTarget.value || !newStatus.value) return
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
rejectionError.value = true
return
}
isUpdatingStatus.value = true
try {
await clientTicketService.updateStatus(statusTarget.value.id, {
status: newStatus.value as ClientTicketStatus,
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
})
statusModalOpen.value = false
await loadTickets()
} finally {
isUpdatingStatus.value = false
}
}
</script>
<style scoped>
.ct-panel-enter-active,
.ct-panel-leave-active {
transition: opacity 0.2s ease;
}
.ct-panel-enter-active > div:last-child,
.ct-panel-leave-active > div:last-child {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-panel-enter-from,
.ct-panel-leave-to {
opacity: 0;
}
.ct-panel-enter-from > div:last-child,
.ct-panel-leave-to > div:last-child {
transform: translateX(100%);
}
.ct-modal-enter-active,
.ct-modal-leave-active {
transition: opacity 0.15s ease;
}
.ct-modal-enter-from,
.ct-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<Transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<h3 class="text-sm font-semibold text-neutral-800">
{{ $t('notification.title') }}
</h3>
<button
v-if="unreadCount > 0"
type="button"
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
@click="handleMarkAllRead"
>
{{ $t('notification.markAllRead') }}
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
</div>
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
{{ $t('notification.empty') }}
</div>
<template v-else>
<button
v-for="notif in notifications"
:key="notif.id"
type="button"
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
:class="{ 'bg-primary-50': !notif.isRead }"
@click="handleClick(notif)"
>
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-neutral-800 truncate">
{{ notif.title }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ notif.message }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ formatRelativeDate(notif.createdAt) }}
</p>
</div>
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Notification } from '~/services/dto/notification'
import { useNotifications } from '~/composables/useNotifications'
const {
unreadCount,
notifications,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
} = useNotifications()
const bellRef = ref<HTMLElement>()
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
fetchNotifications()
}
}
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
if (notif.relatedTicket) {
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
}
async function handleMarkAllRead() {
await markAllAsRead()
}
const { t } = useI18n()
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMin / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMin < 1) return t('notification.timeAgo.now')
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
// Close dropdown when clicking outside
function onClickOutside(event: MouseEvent) {
if (!bellRef.value?.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
startPolling()
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@@ -8,7 +8,15 @@
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
<div class="flex items-center gap-1">
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
</div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
<button

View File

@@ -49,14 +49,15 @@
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
taskId: number
taskId?: number
clientTicketId?: number
}>()
const emit = defineEmits<{
uploaded: []
}>()
const { upload: uploadFile } = useTaskDocumentService()
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
@@ -109,7 +110,11 @@ async function processFiles(files: File[]) {
uploads.value.push(state)
try {
await uploadFile(props.taskId, file)
if (props.clientTicketId) {
await uploadForTicket(props.clientTicketId, file)
} else if (props.taskId) {
await uploadFile(props.taskId, file)
}
state.uploading = false
state.progress = 100
} catch {

View File

@@ -35,6 +35,23 @@
<Icon name="mdi:close" size="20" />
</button>
</div>
<!-- Client ticket link -->
<div
v-if="isEditing && task?.clientTicket"
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
>
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
<span class="text-sm font-medium text-blue-700">
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
</span>
<span
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
:class="ticketStatusClass(task.clientTicket.status)"
>
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
</span>
</div>
</div>
<!-- Body -->
@@ -85,6 +102,14 @@
empty-option-label="Aucun groupe"
min-width="w-full"
/>
<MalioSelect
v-if="clientTicketOptions.length"
v-model="form.clientTicketId"
:options="clientTicketOptions"
label="Ticket client"
empty-option-label="Aucun ticket client"
min-width="w-full"
/>
</div>
<!-- Tags -->
@@ -228,8 +253,10 @@
<script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
@@ -289,6 +316,7 @@ const form = reactive({
assigneeId: null as number | null,
groupId: null as number | null,
tagIds: [] as number[],
clientTicketId: null as number | null,
})
const touched = reactive({
@@ -345,6 +373,7 @@ function populateForm(task: Task | null) {
form.assigneeId = task.assignee?.id ?? null
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null
} else {
form.title = ''
form.description = ''
@@ -354,13 +383,19 @@ function populateForm(task: Task | null) {
form.assigneeId = null
form.groupId = null
form.tagIds = []
form.clientTicketId = null
}
touched.title = false
}
watch(() => props.modelValue, (open) => {
watch(() => props.modelValue, async (open) => {
if (open) {
populateForm(props.task)
try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
} catch {
clientTickets.value = []
}
}
})
@@ -383,11 +418,27 @@ watch(() => props.modelValue, async (open) => {
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([])
const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
)
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
function ticketStatusClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-100 text-blue-700'
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
case 'done': return 'bg-green-100 text-green-700'
case 'rejected': return 'bg-red-100 text-red-700'
default: return 'bg-neutral-100 text-neutral-700'
}
}
const localDocuments = ref<TaskDocument[]>([])
const documents = computed(() => localDocuments.value)
const previewDoc = ref<TaskDocument | null>(null)
@@ -505,6 +556,7 @@ async function handleSubmit() {
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
}
if (isEditing.value && props.task) {

View File

@@ -7,7 +7,8 @@
>
<Icon name="mdi:menu" size="24" />
</button>
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>

View File

@@ -0,0 +1,161 @@
<template>
<div class="date-filter">
<VueDatePicker
v-model="internalValue"
range
:enable-time-picker="false"
:locale="frLocale"
:format="formatDate"
auto-apply
:multi-calendars="false"
position="left"
@update:model-value="onUpdate"
>
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
<div class="relative">
<input
:value="value"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="placeholder || t('common.dateFilter')"
readonly
@click="openMenu"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
/>
<button
v-if="value"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</template>
<template #action-buttons>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import { VueDatePicker } from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
import { fr as frLocale } from 'date-fns/locale'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const internalValue = ref<Date[] | null>(null)
function formatDate(dates: Date[]): string {
if (!dates || dates.length === 0) return ''
if (dates.length === 1) return formatSingleDate(dates[0])
if (isSameDay(dates[0], dates[1])) return formatSingleDate(dates[0])
return `${formatSingleDate(dates[0])} - ${formatSingleDate(dates[1])}`
}
function formatSingleDate(d: Date): string {
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
}
function onUpdate(value: Date[] | null) {
if (!value || value.length === 0) {
emit('update:modelValue', null)
return
}
if (value.length === 2 && isSameDay(value[0], value[1])) {
emit('update:modelValue', value[0])
} else if (value.length === 2) {
emit('update:modelValue', [value[0], value[1]])
}
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
internalValue.value = [today, today]
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
} else if (Array.isArray(val)) {
internalValue.value = [...val]
} else {
internalValue.value = [val, val]
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>

View File

@@ -36,6 +36,39 @@
</div>
</div>
<div class="mt-4">
<MalioSelect
v-model="form.clientId"
label="Client"
:options="clientOptions"
placeholder="Aucun client"
class="w-full"
@update:model-value="onClientChange"
/>
</div>
<div v-if="form.clientId !== null" class="mt-2">
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
<div class="mt-2 flex flex-col gap-2">
<label
v-for="project in filteredProjects"
:key="project.id"
class="flex items-center gap-2 text-sm text-neutral-700"
>
<input
v-model="form.allowedProjectIds"
type="checkbox"
:value="project.id"
class="rounded border-neutral-300"
/>
{{ project.name }}
</label>
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
Aucun projet pour ce client.
</span>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
@@ -52,6 +85,10 @@
<script setup lang="ts">
import type { UserData, UserWrite } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
import { useClientService } from '~/services/clients'
import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project'
const props = defineProps<{
modelValue: boolean
@@ -68,15 +105,32 @@ const isOpen = computed({
set: (v) => emit('update:modelValue', v),
})
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: 'Aucun client', value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
const filteredProjects = computed(() => {
if (form.clientId === null) return []
return allProjects.value.filter(
(p) => p.client !== null && p.client.id === form.clientId,
)
})
const form = reactive({
username: '',
password: '',
roles: [] as string[],
clientId: null as number | null,
allowedProjectIds: [] as number[],
})
const touched = reactive({
@@ -84,19 +138,38 @@ const touched = reactive({
password: false,
})
watch(() => props.modelValue, (open) => {
function onClientChange(value: number | null) {
form.clientId = value
form.allowedProjectIds = []
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
}
}
watch(() => props.modelValue, async (open) => {
if (open) {
if (props.item) {
form.username = props.item.username ?? ''
form.password = ''
form.roles = [...props.item.roles]
form.clientId = props.item.client?.id ?? null
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
} else {
form.username = ''
form.password = ''
form.roles = ['ROLE_USER']
form.clientId = null
form.allowedProjectIds = []
}
touched.username = false
touched.password = false
const [loadedClients, loadedProjects] = await Promise.all([
useClientService().getAll(),
useProjectService().getAll({ archived: false }),
])
clients.value = loadedClients
allProjects.value = loadedProjects
}
})
@@ -113,6 +186,8 @@ async function handleSubmit() {
const payload: UserWrite = {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
}
if (form.password) {
payload.password = form.password

View File

@@ -0,0 +1,29 @@
export function useClientTicketHelpers() {
function typeBadgeClass(type: string): string {
switch (type) {
case 'bug': return 'bg-red-500'
case 'improvement': return 'bg-blue-500'
default: return 'bg-neutral-500'
}
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-100 text-blue-700'
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
case 'done': return 'bg-green-100 text-green-700'
case 'rejected': return 'bg-red-100 text-red-700'
default: return 'bg-neutral-100 text-neutral-700'
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
return { typeBadgeClass, statusBadgeClass, formatDate }
}

View File

@@ -0,0 +1,69 @@
import type { Notification } from '~/services/dto/notification'
import { useNotificationService } from '~/services/notifications'
const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes
export function useNotifications() {
const unreadCount = useState<number>('notification-unread-count', () => 0)
const notifications = useState<Notification[]>('notification-list', () => [])
const isLoading = useState<boolean>('notification-loading', () => false)
const service = useNotificationService()
let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchUnreadCount(): Promise<void> {
try {
unreadCount.value = await service.getUnreadCount()
} catch {
// Silently ignore polling errors
}
}
async function fetchNotifications(): Promise<void> {
isLoading.value = true
try {
notifications.value = await service.getAll()
} finally {
isLoading.value = false
}
}
async function markAsRead(id: number): Promise<void> {
await service.markAsRead(id)
const notif = notifications.value.find(n => n.id === id)
if (notif && !notif.isRead) {
notif.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
async function markAllAsRead(): Promise<void> {
await service.markAllAsRead()
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
}
function startPolling(): void {
fetchUnreadCount()
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
unreadCount,
notifications,
isLoading,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
}
}

View File

@@ -166,7 +166,13 @@
},
"common": {
"cancel": "Annuler",
"loading": "Chargement..."
"save": "Enregistrer",
"edit": "Modifier",
"loading": "Chargement...",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"clear": "Effacer"
},
"gitea": {
"settings": {
@@ -213,6 +219,75 @@
"error": "Erreur de connexion à Gitea.",
"notConfigured": "Gitea non configuré pour ce projet."
},
"portal": {
"title": "Portail client",
"projects": "Vos projets",
"noProjects": "Aucun projet disponible.",
"openTickets": "tickets ouverts",
"newTicket": "Nouveau ticket",
"ticketDetail": "Détail du ticket",
"backToProject": "Retour au projet",
"submitTicket": "Soumettre le ticket",
"ticketCreated": "Ticket soumis avec succès."
},
"clientTicket": {
"title": "Tickets",
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
"improvement": "Amélioration",
"other": "Autre"
},
"status": {
"new": "Nouveau",
"in_progress": "En cours",
"done": "Terminé",
"rejected": "Rejeté"
},
"fields": {
"title": "Titre",
"description": "Description",
"url": "URL de la page",
"urlPlaceholder": "https://example.com/page-concernee",
"type": "Type",
"project": "Projet"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
"rejectComment": "Commentaire de rejet",
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
"linkedTicket": "Lié au ticket client CT-{number}",
"description": "Description",
"url": "URL (page concernée)",
"statusComment": "Commentaire de statut",
"statusChanged": "Statut mis à jour",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"linkedTooltip": "Lié au ticket client {number}",
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
"noTickets": "Aucun ticket.",
"allStatuses": "Tous les statuts",
"allProjects": "Tous les projets",
"submittedBy": "Soumis par",
"createdAt": "Créé le",
"adminTab": "Tickets client",
"selectType": "Type de ticket",
"changeStatus": "Changer le statut"
},
"notification": {
"title": "Notifications",
"markAllRead": "Tout marquer comme lu",
"empty": "Aucune notification",
"ticketCreated": "Nouveau ticket client {number}",
"ticketStatusChanged": "Ticket {number} mis à jour",
"timeAgo": {
"now": "À l'instant",
"minutes": "Il y a {n} min",
"hours": "Il y a {n}h",
"days": "Il y a {n}j"
}
},
"bookstack": {
"settings": {
"title": "Configuration BookStack",

View File

@@ -0,0 +1,87 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto" />
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 px-4 pb-6">
<SidebarLink
to="/portal"
icon="mdi:folder-outline"
label="Mes projets"
:collapsed="false"
class="border-t border-secondary-500 pt-6"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
v-if="isAdmin"
to="/"
icon="mdi:shield-crown-outline"
label="Administration"
:collapsed="false"
class="mt-2"
@click="ui.closeMobileSidebar()"
/>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
<p class="font-bold">v {{ version }}</p>
</div>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0">
<AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppVersion } from '~/composables/useAppVersion'
const auth = useAuthStore()
const ui = useUiStore()
const route = useRoute()
const { version } = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,16 +1,26 @@
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
const isLogin = to.path === '/login'
const auth = useAuthStore()
const isLogin = to.path === '/login'
if (!auth.checked) {
await auth.ensureSession()
}
if (!auth.checked) {
await auth.ensureSession()
}
if (!isLogin && !auth.isAuthenticated) {
return navigateTo('/login')
}
if (!isLogin && !auth.isAuthenticated) {
return navigateTo('/login')
}
if (isLogin && auth.isAuthenticated) {
return navigateTo('/')
}
if (isLogin && auth.isAuthenticated) {
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
return navigateTo(isClientOnly ? '/portal' : '/')
}
// ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) {
const isPortalRoute = to.path.startsWith('/portal')
const isLoginRoute = to.path === '/login'
if (!isPortalRoute && !isLoginRoute) {
return navigateTo('/portal')
}
}
})

View File

@@ -62,5 +62,8 @@ export default defineNuxtConfig({
},
typescript: {
strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
})

View File

@@ -12,6 +12,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
@@ -541,6 +542,12 @@
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dxup/nuxt": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
@@ -1094,6 +1101,68 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5259,6 +5328,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
@@ -5720,6 +5795,62 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT"
},
"node_modules/@vuepic/vue-datepicker": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
"integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@floating-ui/vue": "^1.1.9",
"@vueuse/core": "^14.1.0",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18.12.0"
},
"peerDependencies": {
"vue": ">=3.5.0"
}
},
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -7126,6 +7257,16 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",

View File

@@ -16,6 +16,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",

View File

@@ -27,6 +27,7 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
</div>
@@ -43,6 +44,7 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'client-tickets', label: 'Tickets client' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
] as const

View File

@@ -63,7 +63,8 @@ const handleSubmit = async () => {
try {
await auth.login(username.value, password.value)
await router.push('/')
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
await router.push(isClient ? '/portal' : '/')
} finally {
isSubmitting.value = false
}

View File

@@ -411,12 +411,20 @@ onMounted(() => {
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
<div class="flex items-center gap-1.5">
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
</div>
<p

View File

@@ -0,0 +1,87 @@
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
</div>
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('portal.noProjects') }}
</div>
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="project in projects"
:key="project.id"
:to="`/portal/projects/${project.id}`"
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
>
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
<p class="mt-2 text-sm text-neutral-500">
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
</p>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
definePageMeta({
layout: 'portal',
})
const { t } = useI18n()
useHead({ title: t('portal.title') })
const auth = useAuthStore()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const projects = ref<Project[]>([])
const tickets = ref<ClientTicket[]>([])
const isLoading = ref(true)
const ticketCountByProject = computed(() => {
const counts: Record<number, number> = {}
for (const ticket of tickets.value) {
if (ticket.status === 'new' || ticket.status === 'in_progress') {
// Extract project ID from IRI
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
if (match) {
const projectId = Number(match[1])
counts[projectId] = (counts[projectId] ?? 0) + 1
}
}
}
return counts
})
async function loadData() {
isLoading.value = true
try {
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
// Admin sees all projects
const allProjects = await projectService.getAll({ archived: false })
projects.value = allProjects
} else {
// Client sees allowed projects
projects.value = auth.user?.allowedProjects ?? []
}
tickets.value = await clientTicketService.getAll()
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -0,0 +1,284 @@
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<NuxtLink
to="/portal"
class="text-sm text-neutral-400 hover:text-primary-500"
>
{{ $t('portal.backToProject') }}
</NuxtLink>
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
</div>
<NuxtLink
v-if="isClient"
:to="`/portal/projects/${projectId}/new-ticket`"
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
>
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
<span class="sm:hidden">+ Ticket</span>
</NuxtLink>
</div>
</div>
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<!-- Kanban board -->
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div
v-for="col in columns"
:key="col.status"
class="min-w-0 flex-1 sm:min-w-[280px]"
>
<div class="mb-3 flex items-center gap-2">
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
{{ col.tickets.length }}
</span>
</div>
<div
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
@dragover.prevent="onDragOver(col.status)"
@dragleave="onDragLeave"
@drop.prevent="onDrop(col.status)"
>
<div
v-for="ticket in col.tickets"
:key="ticket.id"
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
:draggable="isAdmin"
@dragstart="onDragStart(ticket)"
@dragend="onDragEnd"
@click="openDetail(ticket)"
>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
</div>
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<p
v-if="col.tickets.length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('clientTicket.noTickets') }}
</p>
</div>
</div>
</div>
<!-- Ticket detail modal -->
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
@refresh="loadTickets"
/>
<!-- Reject comment modal -->
<Teleport v-if="rejectModalOpen" to="body">
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
<textarea
v-model="rejectComment"
rows="3"
class="mt-3 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
:placeholder="$t('clientTicket.rejectComment')"
/>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancelReject"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
:disabled="!rejectComment.trim()"
@click="confirmReject"
>
{{ $t('clientTicket.status.rejected') }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
definePageMeta({
layout: 'portal',
})
const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))
useHead({ title: t('portal.title') })
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const auth = useAuthStore()
const tickets = ref<ClientTicket[]>([])
const projectName = ref('')
const isLoading = ref(true)
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
function statusDotClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-500'
case 'in_progress': return 'bg-yellow-500'
case 'done': return 'bg-green-500'
case 'rejected': return 'bg-red-500'
default: return 'bg-neutral-400'
}
}
const columns = computed(() => allStatuses.map(status => ({
status,
label: t(`clientTicket.status.${status}`),
dotClass: statusDotClass(status),
tickets: tickets.value.filter(tk => tk.status === status),
})))
// Drag & drop (admin only)
const draggedTicket = ref<ClientTicket | null>(null)
const dragOverStatus = ref<ClientTicketStatus | null>(null)
function onDragStart(ticket: ClientTicket) {
draggedTicket.value = ticket
}
function onDragEnd() {
draggedTicket.value = null
dragOverStatus.value = null
}
function onDragOver(status: ClientTicketStatus) {
if (!draggedTicket.value) return
dragOverStatus.value = status
}
function onDragLeave() {
dragOverStatus.value = null
}
async function onDrop(newStatus: ClientTicketStatus) {
dragOverStatus.value = null
const ticket = draggedTicket.value
draggedTicket.value = null
if (!ticket || ticket.status === newStatus) return
// Rejected requires a comment
if (newStatus === 'rejected') {
pendingRejectTicket.value = ticket
rejectComment.value = ''
rejectModalOpen.value = true
return
}
// Optimistic update
const oldStatus = ticket.status
ticket.status = newStatus
try {
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
await loadTickets()
} catch {
ticket.status = oldStatus
}
}
// Reject modal
const rejectModalOpen = ref(false)
const rejectComment = ref('')
const pendingRejectTicket = ref<ClientTicket | null>(null)
function cancelReject() {
rejectModalOpen.value = false
pendingRejectTicket.value = null
rejectComment.value = ''
}
async function confirmReject() {
const ticket = pendingRejectTicket.value
if (!ticket || !rejectComment.value.trim()) return
const oldStatus = ticket.status
ticket.status = 'rejected'
rejectModalOpen.value = false
try {
await clientTicketService.updateStatus(ticket.id, {
status: 'rejected',
statusComment: rejectComment.value.trim(),
})
await loadTickets()
} catch {
ticket.status = oldStatus
}
pendingRejectTicket.value = null
rejectComment.value = ''
}
function openDetail(ticket: ClientTicket) {
selectedTicket.value = ticket
detailOpen.value = true
}
async function loadData() {
isLoading.value = true
try {
const [ticketList, project] = await Promise.all([
clientTicketService.getAll({ project: projectId.value }),
projectService.getById(projectId.value),
])
tickets.value = ticketList
projectName.value = project.name
} finally {
isLoading.value = false
}
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll({ project: projectId.value })
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -0,0 +1,134 @@
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<NuxtLink
:to="`/portal/projects/${projectId}`"
class="text-sm text-neutral-400 hover:text-primary-500"
>
{{ $t('portal.backToProject') }}
</NuxtLink>
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
</div>
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
<!-- Type -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
<select
v-model="form.type"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
<option value="other">{{ $t('clientTicket.type.other') }}</option>
</select>
</div>
<!-- Title -->
<div class="mt-4">
<MalioInputText
v-model="form.title"
:label="$t('clientTicket.title')"
input-class="w-full"
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
@blur="touched.title = true"
/>
</div>
<!-- Description -->
<div class="mt-4">
<MalioInputTextArea
v-model="form.description"
:label="$t('clientTicket.description')"
:size="5"
/>
</div>
<!-- URL (only for bug type) -->
<div v-if="form.type === 'bug'" class="mt-4">
<MalioInputText
v-model="form.url"
:label="$t('clientTicket.url')"
input-class="w-full"
/>
</div>
<!-- Document upload (only after ticket is created) -->
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
<p class="text-sm text-neutral-500">
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
Les documents pourront être ajoutés après la soumission du ticket.
</p>
</div>
<!-- Submit -->
<div class="mt-6 flex items-center gap-3">
<NuxtLink
:to="`/portal/projects/${projectId}`"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
>
{{ $t('common.cancel') }}
</NuxtLink>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
{{ $t('portal.submitTicket') }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import type { ClientTicketType } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
definePageMeta({
layout: 'portal',
})
const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))
useHead({ title: t('portal.newTicket') })
const clientTicketService = useClientTicketService()
const form = reactive({
type: 'bug' as ClientTicketType | string,
title: '',
description: '',
url: '',
})
const touched = reactive({
title: false,
})
const isSubmitting = ref(false)
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
if (!form.description.trim()) return
isSubmitting.value = true
try {
await clientTicketService.create({
type: form.type as ClientTicketType,
title: form.title.trim(),
description: form.description.trim(),
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
project: `/api/projects/${projectId.value}`,
})
await navigateTo(`/portal/projects/${projectId.value}`)
} catch {
// Toast already shown by useApi
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -3,13 +3,22 @@
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<div class="flex items-center gap-2">
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<button
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
title="Paramètres du projet"
@click="projectDrawerOpen = true"
>
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
@@ -120,6 +129,13 @@
@saved="onSaved"
/>
<ProjectDrawer
v-model="projectDrawerOpen"
:project="project"
:clients="clients"
@saved="onProjectSaved"
/>
</div>
</template>
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects'
import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Projet' })
const projectService = useProjectService()
const clientService = useClientService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
@@ -163,6 +182,7 @@ const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const clients = ref<Client[]>([])
const isLoading = ref(true)
const selectedGroupId = ref<number | null>(null)
@@ -172,6 +192,7 @@ const selectedStatusId = ref<number | null>(null)
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
const projectDrawerOpen = ref(false)
const selectedTask = ref<Task | null>(null)
const groupFilterOptions = computed(() =>
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
async function loadData() {
isLoading.value = true
try {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value),
statusService.getAll(),
@@ -227,6 +248,7 @@ async function loadData() {
tagService.getAll(),
groupService.getByProject(projectId.value),
userService.getAll(),
clientService.getAll(),
])
project.value = p
tasks.value = t
@@ -236,6 +258,7 @@ async function loadData() {
tags.value = ty
groups.value = g
users.value = u
clients.value = c
} finally {
isLoading.value = false
}
@@ -290,6 +313,10 @@ async function onSaved() {
await loadData()
}
async function onProjectSaved() {
await loadData()
}
onMounted(() => {
loadData()
})

View File

@@ -70,6 +70,8 @@
text-value="text-sm"
/>
</div>
<DateFilter v-model="selectedDateFilter" />
</div>
</div>
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTagId = ref<number | null>(null)
const selectedProjectId = ref<number | null>(null)
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
@@ -189,6 +192,28 @@ const filteredEntries = computed(() => {
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result
})

View File

@@ -0,0 +1,46 @@
import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useClientTicketService() {
const api = useApi()
async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]> {
const query: Record<string, unknown> = {}
if (params?.project) query.project = `/api/projects/${params.project}`
if (params?.status) query.status = params.status
if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}`
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', query)
return extractHydraMembers(data)
}
async function getById(id: number): Promise<ClientTicket> {
return api.get<ClientTicket>(`/client_tickets/${id}`)
}
async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
toastSuccessKey: 'portal.ticketCreated',
})
}
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.statusUpdated',
})
}
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.statusUpdated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/client_tickets/${id}`, {}, {
toastSuccessKey: 'clientTicket.deleted',
})
}
return { getAll, getById, create, update, updateStatus, remove }
}

View File

@@ -0,0 +1,34 @@
import type { TaskDocument } from './task-document'
export type ClientTicketType = 'bug' | 'improvement' | 'other'
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
export type ClientTicket = {
'@id'?: string
id: number
number: number
type: ClientTicketType
title: string
description: string
url: string | null
status: ClientTicketStatus
statusComment: string | null
project: string
submittedBy: string | null
createdAt: string
updatedAt: string
documents?: TaskDocument[]
}
export type ClientTicketWrite = {
type: ClientTicketType
title: string
description: string
url?: string | null
project: string
}
export type ClientTicketStatusUpdate = {
status: ClientTicketStatus
statusComment?: string | null
}

View File

@@ -0,0 +1,13 @@
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
export type Notification = {
'@id'?: string
id: number
user: string
type: NotificationType
title: string
message: string
relatedTicket: string | null
isRead: boolean
createdAt: string
}

View File

@@ -22,6 +22,13 @@ export type Task = {
tags: TaskTag[]
documents: TaskDocument[]
archived: boolean
clientTicket: {
id: number
number: number
type: string
status: string
title: string
} | null
}
export type TaskWrite = {
@@ -35,4 +42,5 @@ export type TaskWrite = {
project: string
tags: string[]
archived?: boolean
clientTicket?: string | null
}

View File

@@ -1,12 +1,18 @@
import type { Project } from './project'
export type UserData = {
id: number
'@id'?: string
username: string
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
}
export type UserWrite = {
username: string
password?: string
roles: string[]
client?: string | null
allowedProjects?: string[]
}

View File

@@ -0,0 +1,33 @@
import type { Notification } from './dto/notification'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useNotificationService() {
const api = useApi()
async function getAll(): Promise<Notification[]> {
const data = await api.get<HydraCollection<Notification>>('/notifications')
return extractHydraMembers(data)
}
async function markAsRead(id: number): Promise<void> {
await api.patch(`/notifications/${id}`, { isRead: true }, {
toast: false,
})
}
async function markAllAsRead(): Promise<void> {
await api.post('/notifications/mark-all-read', {}, {
toast: false,
})
}
async function getUnreadCount(): Promise<number> {
const data = await api.get<{ count: number }>('/notifications/unread-count', {}, {
toast: false,
})
return data.count
}
return { getAll, markAsRead, markAllAsRead, getUnreadCount }
}

View File

@@ -28,6 +28,26 @@ export function useTaskDocumentService() {
})
}
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
}
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
clientTicket: `/api/client_tickets/${clientTicketId}`,
})
return extractHydraMembers(data)
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_documents/${id}`, {}, {
toastSuccessKey: 'taskDocuments.deleted',
@@ -38,5 +58,5 @@ export function useTaskDocumentService() {
return `${baseURL}/task_documents/${id}/download`
}
return { getByTask, upload, remove, getDownloadUrl }
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315182512 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE client_ticket (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, number INT NOT NULL, type VARCHAR(20) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, url VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, status_comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, project_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_C206E610166D1F9C ON client_ticket (project_id)');
$this->addSql('CREATE INDEX IDX_C206E61079F7D87D ON client_ticket (submitted_by_id)');
$this->addSql('CREATE TABLE user_allowed_projects (user_id INT NOT NULL, project_id INT NOT NULL, PRIMARY KEY (user_id, project_id))');
$this->addSql('CREATE INDEX IDX_B3E0FC97A76ED395 ON user_allowed_projects (user_id)');
$this->addSql('CREATE INDEX IDX_B3E0FC97166D1F9C ON user_allowed_projects (project_id)');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61079F7D87D FOREIGN KEY (submitted_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE task ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB259B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_527EDB259B2097DD ON task (client_ticket_id)');
$this->addSql('COMMENT ON COLUMN task_book_stack_link.created_at IS \'\'');
$this->addSql('ALTER TABLE task_document ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task_document ALTER task_id DROP NOT NULL');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_98A9603A9B2097DD ON task_document (client_ticket_id)');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_document_owner CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)');
$this->addSql('ALTER TABLE "user" ADD client_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D64919EB6921 FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_8D93D64919EB6921 ON "user" (client_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610166D1F9C');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61079F7D87D');
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97A76ED395');
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97166D1F9C');
$this->addSql('DROP TABLE client_ticket');
$this->addSql('DROP TABLE user_allowed_projects');
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB259B2097DD');
$this->addSql('DROP INDEX IDX_527EDB259B2097DD');
$this->addSql('ALTER TABLE task DROP client_ticket_id');
$this->addSql('COMMENT ON COLUMN task_book_stack_link.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A9B2097DD');
$this->addSql('DROP INDEX IDX_98A9603A9B2097DD');
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT chk_document_owner');
$this->addSql('ALTER TABLE task_document DROP client_ticket_id');
$this->addSql('ALTER TABLE task_document ALTER task_id SET NOT NULL');
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D64919EB6921');
$this->addSql('DROP INDEX IDX_8D93D64919EB6921');
$this->addSql('ALTER TABLE "user" DROP client_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315183313 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD api_token VARCHAR(64) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D6497BA2F5EB ON "user" (api_token)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX UNIQ_8D93D6497BA2F5EB');
$this->addSql('ALTER TABLE "user" DROP api_token');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315184538 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE notification (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, related_ticket_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_BF5476CAD8C11BC9 ON notification (related_ticket_id)');
$this->addSql('CREATE INDEX idx_notification_user ON notification (user_id)');
$this->addSql('CREATE INDEX idx_notification_user_read ON notification (user_id, is_read)');
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAD8C11BC9 FOREIGN KEY (related_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAD8C11BC9');
$this->addSql('DROP TABLE notification');
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: 'app:generate-api-token',
description: 'Generate or regenerate an API token for a user (used for MCP HTTP authentication)',
)]
class GenerateApiTokenCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$user = $this->userRepository->findOneBy(['username' => $username]);
if (null === $user) {
$io->error(sprintf('User "%s" not found.', $username));
return Command::FAILURE;
}
$token = bin2hex(random_bytes(32));
$user->setApiToken($token);
$this->entityManager->flush();
$io->success(sprintf('API token generated for user "%s":', $username));
$io->writeln($token);
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
return new Response(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $count]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Client;
use App\Entity\ClientTicket;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskEffort;
@@ -28,13 +29,32 @@ class AppFixtures extends Fixture
public function load(ObjectManager $manager): void
{
// User admin
// Users
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
$manager->persist($admin);
$userAlice = new User();
$userAlice->setUsername('alice');
$userAlice->setRoles(['ROLE_USER']);
$userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice'));
$manager->persist($userAlice);
$userBob = new User();
$userBob->setUsername('bob');
$userBob->setRoles(['ROLE_USER']);
$userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
$manager->persist($userBob);
$userCharlie = new User();
$userCharlie->setUsername('charlie');
$userCharlie->setRoles(['ROLE_USER']);
$userCharlie->setPassword($this->passwordHasher->hashPassword($userCharlie, 'charlie'));
$manager->persist($userCharlie);
// Clients
$clientLiot = new Client();
$clientLiot->setName('LIOT');
@@ -250,7 +270,7 @@ class AppFixtures extends Fixture
$task2->setStatus($statusTodo);
$task2->setEffort($effortL);
$task2->setPriority($priorityHigh);
$task2->setAssignee($admin);
$task2->setAssignee($userAlice);
$task2->setGroup($groupFrontend);
$task2->setProject($projectSirh);
$task2->addTag($tagAuth);
@@ -274,7 +294,7 @@ class AppFixtures extends Fixture
$task4->setStatus($statusBlocked);
$task4->setEffort($effortXXL);
$task4->setPriority($priorityLow);
$task4->setAssignee($admin);
$task4->setAssignee($userBob);
$task4->setProject($projectSirh);
$task4->addTag($tagPassword);
$manager->persist($task4);
@@ -285,7 +305,7 @@ class AppFixtures extends Fixture
$task5->setStatus($statusReview);
$task5->setEffort($effortXXL);
$task5->setPriority($priorityMedium);
$task5->setAssignee($admin);
$task5->setAssignee($userCharlie);
$task5->setProject($projectSirh);
$task5->addTag($tagCalendar);
$manager->persist($task5);
@@ -321,7 +341,7 @@ class AppFixtures extends Fixture
$taskCrm2->setStatus($statusInProgress);
$taskCrm2->setEffort($effortM);
$taskCrm2->setPriority($priorityMedium);
$taskCrm2->setAssignee($admin);
$taskCrm2->setAssignee($userAlice);
$taskCrm2->setGroup($groupCrmUi);
$taskCrm2->setProject($projectCrm);
$manager->persist($taskCrm2);
@@ -343,7 +363,7 @@ class AppFixtures extends Fixture
$taskCrm4->setStatus($statusInProgress);
$taskCrm4->setEffort($effortXXL);
$taskCrm4->setPriority($priorityHigh);
$taskCrm4->setAssignee($admin);
$taskCrm4->setAssignee($userBob);
$taskCrm4->setGroup($groupCrmUi);
$taskCrm4->setProject($projectCrm);
$taskCrm4->addTag($tagCalendar);
@@ -380,7 +400,7 @@ class AppFixtures extends Fixture
$taskErp2->setStatus($statusInProgress);
$taskErp2->setEffort($effortM);
$taskErp2->setPriority($priorityHigh);
$taskErp2->setAssignee($admin);
$taskErp2->setAssignee($userCharlie);
$taskErp2->setGroup($groupErpStock);
$taskErp2->setProject($projectErp);
$manager->persist($taskErp2);
@@ -450,7 +470,7 @@ class AppFixtures extends Fixture
$taskSite2->setStatus($statusInProgress);
$taskSite2->setEffort($effortL);
$taskSite2->setPriority($priorityMedium);
$taskSite2->setAssignee($admin);
$taskSite2->setAssignee($userAlice);
$taskSite2->setGroup($groupSiteDesign);
$taskSite2->setProject($projectInterne);
$manager->persist($taskSite2);
@@ -542,6 +562,94 @@ class AppFixtures extends Fixture
$manager->persist($entry);
}
// =============================================
// Client Users
// =============================================
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// =============================================
// Client Tickets
// =============================================
$ticket1 = new ClientTicket();
$ticket1->setNumber(1);
$ticket1->setType('bug');
$ticket1->setTitle('Erreur 500 sur la page de login');
$ticket1->setDescription('Quand je clique sur "Se connecter" avec un mot de passe vide, j\'obtiens une page blanche avec une erreur 500.');
$ticket1->setUrl('https://sirh.liot.fr/login');
$ticket1->setStatus('new');
$ticket1->setProject($projectSirh);
$ticket1->setSubmittedBy($clientUserLiot);
$ticket1->setCreatedAt(new DateTimeImmutable('-3 days'));
$ticket1->setUpdatedAt(new DateTimeImmutable('-3 days'));
$manager->persist($ticket1);
$ticket2 = new ClientTicket();
$ticket2->setNumber(2);
$ticket2->setType('improvement');
$ticket2->setTitle('Ajouter un export PDF des fiches employés');
$ticket2->setDescription('Il serait utile de pouvoir exporter les fiches employés au format PDF pour les archiver.');
$ticket2->setStatus('in_progress');
$ticket2->setProject($projectSirh);
$ticket2->setSubmittedBy($clientUserLiot);
$ticket2->setCreatedAt(new DateTimeImmutable('-7 days'));
$ticket2->setUpdatedAt(new DateTimeImmutable('-2 days'));
$manager->persist($ticket2);
$ticket3 = new ClientTicket();
$ticket3->setNumber(3);
$ticket3->setType('other');
$ticket3->setTitle('Demande de formation sur le module congés');
$ticket3->setDescription('Notre équipe RH souhaiterait une formation sur le nouveau module de gestion des congés.');
$ticket3->setStatus('done');
$ticket3->setStatusComment('Formation planifiée le 20/03. Ticket clos.');
$ticket3->setProject($projectSirh);
$ticket3->setSubmittedBy($clientUserLiot);
$ticket3->setCreatedAt(new DateTimeImmutable('-14 days'));
$ticket3->setUpdatedAt(new DateTimeImmutable('-5 days'));
$manager->persist($ticket3);
$ticket4 = new ClientTicket();
$ticket4->setNumber(1);
$ticket4->setType('bug');
$ticket4->setTitle('Doublons dans la liste des contacts');
$ticket4->setDescription('Certains contacts apparaissent en double après l\'import CSV. Le problème semble lié aux accents dans les noms.');
$ticket4->setStatus('new');
$ticket4->setProject($projectCrm);
$ticket4->setSubmittedBy($clientUserAcme);
$ticket4->setCreatedAt(new DateTimeImmutable('-1 day'));
$ticket4->setUpdatedAt(new DateTimeImmutable('-1 day'));
$manager->persist($ticket4);
$ticket5 = new ClientTicket();
$ticket5->setNumber(2);
$ticket5->setType('improvement');
$ticket5->setTitle('Filtre par date sur le pipeline de vente');
$ticket5->setDescription('Pouvoir filtrer le pipeline de vente par période (mois, trimestre, année).');
$ticket5->setStatus('rejected');
$ticket5->setStatusComment('Cette fonctionnalité est déjà prévue dans la prochaine version. Pas besoin de ticket spécifique.');
$ticket5->setProject($projectCrm);
$ticket5->setSubmittedBy($clientUserAcme);
$ticket5->setCreatedAt(new DateTimeImmutable('-10 days'));
$ticket5->setUpdatedAt(new DateTimeImmutable('-8 days'));
$manager->persist($ticket5);
// Link a task to a client ticket
$task3->setClientTicket($ticket1);
$manager->flush();
}
}

View File

@@ -18,8 +18,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

261
src/Entity/ClientTicket.php Normal file
View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ClientTicketRepository;
use App\State\ClientTicketNumberProcessor;
use App\State\ClientTicketProvider;
use App\State\ClientTicketStatusProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Get(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Post(
security: "is_granted('ROLE_CLIENT')",
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)",
processor: ClientTicketStatusProcessor::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['client_ticket:read']],
denormalizationContext: ['groups' => ['client_ticket:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
#[ORM\Table(
name: 'client_ticket',
uniqueConstraints: [
new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']),
],
)]
class ClientTicket
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $url = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ?string $status = 'new';
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?Project $project = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?User $submittedBy = null;
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])]
#[Groups(['client_ticket:read'])]
private Collection $documents;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['client_ticket:read'])]
private ?DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
}

160
src/Entity/Notification.php Normal file
View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
provider: NotificationProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Patch(
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
),
],
normalizationContext: ['groups' => ['notification:read']],
denormalizationContext: ['groups' => ['notification:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['notification:read'])]
private ?User $user = null;
#[ORM\Column(length: 50)]
#[Groups(['notification:read'])]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['notification:read'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Groups(['notification:read'])]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['notification:read'])]
private ?ClientTicket $relatedTicket = null;
#[ORM\Column]
#[Groups(['notification:read', 'notification:write'])]
private bool $isRead = false;
#[ORM\Column]
#[Groups(['notification:read'])]
private ?DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getRelatedTicket(): ?ClientTicket
{
return $this->relatedTicket;
}
public function setRelatedTicket(?ClientTicket $relatedTicket): static
{
$this->relatedTicket = $relatedTicket;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): static
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}

View File

@@ -20,8 +20,8 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],

View File

@@ -22,8 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
@@ -104,6 +104,11 @@ class Task
#[Groups(['task:read'])]
private Collection $documents;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicket $clientTicket = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -262,4 +267,16 @@ class Task
{
return $this->documents;
}
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
}

View File

@@ -19,10 +19,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
@@ -40,37 +40,42 @@ class TaskDocument
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?ClientTicket $clientTicket = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?string $fileName = null;
#[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read'])]
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
private ?User $uploadedBy = null;
public function getId(): ?int
@@ -161,4 +166,16 @@ class TaskDocument
return $this;
}
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
}

View File

@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -19,8 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -24,15 +24,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(
name: 'active_time_entry',
uriTemplate: '/time_entries/active',
provider: ActiveTimeEntryProvider::class,
description: 'Get the active timer for the current user',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
),
new Get(),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),

View File

@@ -14,6 +14,8 @@ use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -46,11 +48,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])]
private ?string $username = null;
/** @var list<string> */
@@ -65,9 +67,24 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)]
private ?string $apiToken = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?Client $client = null;
/** @var Collection<int, Project> */
#[ORM\ManyToMany(targetEntity: Project::class)]
#[ORM\JoinTable(name: 'user_allowed_projects')]
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $allowedProjects;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->allowedProjects = new ArrayCollection();
}
public function getId(): ?int
@@ -95,8 +112,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
$roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
@@ -133,5 +153,51 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
/** @return Collection<int, Project> */
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(Project $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(Project $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
public function getApiToken(): ?string
{
return $this->apiToken;
}
public function setApiToken(?string $apiToken): static
{
$this->apiToken = $apiToken;
return $this;
}
public function eraseCredentials(): void {}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Entity\Project;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
class CreateProjectTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository,
) {}
public function __invoke(
string $name,
string $code,
?string $description = null,
?string $color = null,
?int $clientId = null,
): string {
$project = new Project();
$project->setName($name);
$project->setCode($code);
if (null !== $description) {
$project->setDescription($description);
}
if (null !== $color) {
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$project->setClient($client);
}
$this->entityManager->persist($project);
$this->entityManager->flush();
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
class GetProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
) {}
public function __invoke(int $id): string
{
$project = $this->projectRepository->find($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
// Count tasks per status
$qb = $this->taskRepository->createQueryBuilder('t')
->select('s.label AS statusLabel, COUNT(t.id) AS taskCount')
->leftJoin('t.status', 's')
->where('t.project = :project')
->setParameter('project', $project)
->groupBy('s.id, s.label')
;
$statusCounts = [];
$totalTasks = 0;
foreach ($qb->getQuery()->getResult() as $row) {
$label = $row['statusLabel'] ?? 'No status';
$count = (int) $row['taskCount'];
$statusCounts[$label] = $count;
$totalTasks += $count;
}
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
'taskSummary' => $statusCounts,
'totalTasks' => $totalTasks,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
class ListProjectsTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
) {}
public function __invoke(bool $archived = false): string
{
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
return json_encode(array_map(fn ($project) => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
], $projects));
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ClientRepository;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
class UpdateProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $code = null,
?string $description = null,
?string $color = null,
?int $clientId = null,
?bool $archived = null,
): string {
$project = $this->projectRepository->find($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
if (null !== $name) {
$project->setName($name);
}
if (null !== $code) {
$project->setCode($code);
}
if (null !== $description) {
$project->setDescription($description);
}
if (null !== $color) {
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$project->setClient($client);
}
if (null !== $archived) {
$project->setArchived($archived);
}
$this->entityManager->flush();
return json_encode([
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
class ListClientsTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
) {}
public function __invoke(): string
{
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($client) => [
'id' => $client->getId(),
'name' => $client->getName(),
'email' => $client->getEmail(),
], $clients));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function __invoke(): string
{
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
return json_encode(array_map(fn ($user) => [
'id' => $user->getId(),
'username' => $user->getUsername(),
], $users));
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\Task;
use App\Repository\ProjectRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
class CreateTaskTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
) {}
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
): string {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$task = new Task();
$task->setProject($project);
$task->setTitle($title);
$task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1);
if (null !== $description) {
$task->setDescription($description);
}
if (null !== $statusId) {
$status = $this->taskStatusRepository->find($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->find($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->find($effortId);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId));
}
$task->setEffort($effort);
}
if (null !== $assigneeId) {
$assignee = $this->userRepository->find($assigneeId);
if (null === $assignee) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId));
}
$task->setAssignee($assignee);
}
if (null !== $groupId) {
$group = $this->taskGroupRepository->find($groupId);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId));
}
$task->setGroup($group);
}
if (null !== $tagIds) {
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$task->addTag($tag);
}
}
$this->entityManager->persist($task);
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
class DeleteTaskTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(int $id): string
{
$task = $this->taskRepository->find($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
$this->entityManager->remove($task);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => sprintf('Task %s deleted.', $taskCode),
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
class GetTaskTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {}
public function __invoke(int $id): string
{
$task = $this->taskRepository->find($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
'isFinal' => $task->getStatus()->getIsFinal(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
'color' => $task->getGroup()->getColor(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
])->toArray(),
'documents' => $task->getDocuments()->map(fn ($doc) => [
'id' => $doc->getId(),
'originalName' => $doc->getOriginalName(),
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()?->format('c'),
'uploadedBy' => $doc->getUploadedBy() ? [
'id' => $doc->getUploadedBy()->getId(),
'username' => $doc->getUploadedBy()->getUsername(),
] : null,
])->toArray(),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
class ListTasksTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {}
public function __invoke(
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
bool $archived = false,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t')
->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g')
->leftJoin('t.tags', 'tg')->addSelect('tg')
->where('t.archived = :archived')
->setParameter('archived', $archived)
->orderBy('t.id', 'DESC')
->setMaxResults($limit)
;
if (null !== $projectId) {
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
}
if (null !== $statusId) {
$qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId);
}
if (null !== $assigneeId) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
}
if (null !== $priorityId) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
}
if (null !== $groupId) {
$qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId);
}
$tasks = $qb->getQuery()->getResult();
if (null !== $tagIds) {
$tasks = array_filter($tasks, function ($task) use ($tagIds) {
$taskTagIds = $task->getTags()->map(fn ($t) => $t->getId())->toArray();
return !empty(array_intersect($tagIds, $taskTagIds));
});
}
return json_encode(array_map(fn ($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
], array_values($tasks)));
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
class UpdateTaskTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
) {}
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?bool $archived = null,
): string {
$task = $this->taskRepository->find($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
if (null !== $title) {
$task->setTitle($title);
}
if (null !== $description) {
$task->setDescription($description);
}
if (null !== $statusId) {
$status = $this->taskStatusRepository->find($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->find($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->find($effortId);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId));
}
$task->setEffort($effort);
}
if (null !== $assigneeId) {
$assignee = $this->userRepository->find($assigneeId);
if (null === $assignee) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId));
}
$task->setAssignee($assignee);
}
if (null !== $groupId) {
$group = $this->taskGroupRepository->find($groupId);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId));
}
$task->setGroup($group);
}
if (null !== $tagIds) {
// Clear existing tags and set new ones
foreach ($task->getTags()->toArray() as $existingTag) {
$task->removeTag($existingTag);
}
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$task->addTag($tag);
}
}
if (null !== $archived) {
$task->setArchived($archived);
}
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskGroup;
use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
class CreateGroupTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository,
) {}
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?string $color = null,
): string {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$group = new TaskGroup();
$group->setProject($project);
$group->setTitle($title);
if (null !== $description) {
$group->setDescription($description);
}
if (null !== $color) {
$group->setColor($color);
}
$this->entityManager->persist($group);
$this->entityManager->flush();
return json_encode([
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
],
'archived' => $group->isArchived(),
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
class ListEffortsTool
{
public function __construct(
private readonly TaskEffortRepository $taskEffortRepository,
) {}
public function __invoke(): string
{
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($e) => [
'id' => $e->getId(),
'label' => $e->getLabel(),
], $efforts));
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
class ListGroupsTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
) {}
public function __invoke(?int $projectId = null, bool $archived = false): string
{
$criteria = ['archived' => $archived];
if (null !== $projectId) {
$criteria['project'] = $projectId;
}
$groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']);
return json_encode(array_map(fn ($g) => [
'id' => $g->getId(),
'title' => $g->getTitle(),
'description' => $g->getDescription(),
'color' => $g->getColor(),
'project' => [
'id' => $g->getProject()->getId(),
'code' => $g->getProject()->getCode(),
'name' => $g->getProject()->getName(),
],
'archived' => $g->isArchived(),
], $groups));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
class ListPrioritiesTool
{
public function __construct(
private readonly TaskPriorityRepository $taskPriorityRepository,
) {}
public function __invoke(): string
{
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($p) => [
'id' => $p->getId(),
'label' => $p->getLabel(),
'color' => $p->getColor(),
], $priorities));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
class ListStatusesTool
{
public function __construct(
private readonly TaskStatusRepository $taskStatusRepository,
) {}
public function __invoke(): string
{
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($s) => [
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
], $statuses));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
class ListTagsTool
{
public function __construct(
private readonly TaskTagRepository $taskTagRepository,
) {}
public function __invoke(): string
{
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
], $tags));
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
class UpdateGroupTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?string $color = null,
?bool $archived = null,
): string {
$group = $this->taskGroupRepository->find($id);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id));
}
if (null !== $title) {
$group->setTitle($title);
}
if (null !== $description) {
$group->setDescription($description);
}
if (null !== $color) {
$group->setColor($color);
}
if (null !== $archived) {
$group->setArchived($archived);
}
$this->entityManager->flush();
return json_encode([
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => [
'id' => $group->getProject()->getId(),
'code' => $group->getProject()->getCode(),
'name' => $group->getProject()->getName(),
],
'archived' => $group->isArchived(),
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'create-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
class CreateTimeEntryTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
) {}
public function __invoke(
int $userId,
string $startedAt,
?string $title = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
$user = $this->userRepository->find($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
// Check for existing active timer if creating a new active one
if (null === $stoppedAt) {
$activeEntry = $this->timeEntryRepository->findActiveByUser($user);
if (null !== $activeEntry) {
throw new InvalidArgumentException(sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId()));
}
}
$entry = new TimeEntry();
$entry->setUser($user);
$entry->setStartedAt(new DateTimeImmutable($startedAt));
if (null !== $title) {
$entry->setTitle($title);
}
if (null !== $stoppedAt) {
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
}
if (null !== $description) {
$entry->setDescription($description);
}
if (null !== $projectId) {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$entry->setProject($project);
}
if (null !== $taskId) {
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$entry->setTask($task);
}
if (null !== $tagIds) {
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$entry->addTag($tag);
}
}
$this->entityManager->persist($entry);
$this->entityManager->flush();
return json_encode([
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => ['id' => $user->getId(), 'username' => $user->getUsername()],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\TimeEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
class DeleteTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(int $id): string
{
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
}
$this->entityManager->remove($entry);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => 'Time entry deleted.',
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
) {}
public function __invoke(
?int $userId = null,
?int $projectId = null,
?int $taskId = null,
?string $startDate = null,
?string $endDate = null,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->timeEntryRepository->createQueryBuilder('te')
->leftJoin('te.user', 'u')->addSelect('u')
->leftJoin('te.project', 'p')->addSelect('p')
->leftJoin('te.task', 't')->addSelect('t')
->leftJoin('te.tags', 'tg')->addSelect('tg')
->orderBy('te.startedAt', 'DESC')
->setMaxResults($limit)
;
if (null !== $userId) {
$qb->andWhere('u.id = :userId')->setParameter('userId', $userId);
}
if (null !== $projectId) {
$qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId);
}
if (null !== $taskId) {
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
}
if (null !== $startDate) {
$qb->andWhere('te.startedAt >= :startDate')
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
;
}
if (null !== $endDate) {
$qb->andWhere('te.startedAt <= :endDate')
->setParameter('endDate', new DateTimeImmutable($endDate.' 23:59:59'))
;
}
$entries = $qb->getQuery()->getResult();
return json_encode(array_map(fn ($entry) => [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => [
'id' => $entry->getUser()->getId(),
'username' => $entry->getUser()->getUsername(),
],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
], $entries));
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use function sprintf;
#[McpTool(name: 'update-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
class UpdateTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(
int $id,
?string $title = null,
?string $startedAt = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
}
if (null !== $title) {
$entry->setTitle($title);
}
if (null !== $startedAt) {
$entry->setStartedAt(new DateTimeImmutable($startedAt));
}
if (null !== $stoppedAt) {
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
}
if (null !== $description) {
$entry->setDescription($description);
}
if (null !== $projectId) {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$entry->setProject($project);
}
if (null !== $taskId) {
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$entry->setTask($task);
}
if (null !== $tagIds) {
foreach ($entry->getTags()->toArray() as $existingTag) {
$entry->removeTag($existingTag);
}
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$entry->addTag($tag);
}
}
$this->entityManager->flush();
return json_encode([
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => $entry->getStoppedAt() && $entry->getStartedAt()
? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60)
: null,
'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()],
'project' => $entry->getProject() ? [
'id' => $entry->getProject()->getId(),
'code' => $entry->getProject()->getCode(),
'name' => $entry->getProject()->getName(),
] : null,
'task' => $entry->getTask() ? [
'id' => $entry->getTask()->getId(),
'number' => $entry->getTask()->getNumber(),
'title' => $entry->getTask()->getTitle(),
] : null,
'tags' => $entry->getTags()->map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ClientTicket;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
class ClientTicketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
public function findNextNumberForProject(Project $project): int
{
$result = $this->createQueryBuilder('ct')
->select('MAX(ct.number)')
->where('ct.project = :project')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult()
;
return ((int) $result) + 1;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
public function countUnreadByUser(User $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult()
;
}
public function markAllReadByUser(User $user): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', 'true')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->executeStatement()
;
}
}

View File

@@ -17,4 +17,25 @@ class UserRepository extends ServiceEntityRepository
{
parent::__construct($registry, User::class);
}
/**
* @return User[]
*/
public function findByRole(string $role): array
{
$conn = $this->getEntityManager()->getConnection();
$sql = 'SELECT id FROM "user" WHERE roles::text LIKE :role';
$ids = $conn->executeQuery($sql, ['role' => '%"'.$role.'"%'])->fetchFirstColumn();
if ([] === $ids) {
return [];
}
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization')
&& str_starts_with((string) $request->headers->get('Authorization'), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
$authHeader = (string) $request->headers->get('Authorization');
$token = substr($authHeader, 7);
if ('' === $token) {
throw new CustomUserMessageAuthenticationException('API token missing.');
}
return new SelfValidatingPassport(
new UserBadge($token, function (string $token): ?User {
$user = $this->userRepository->findOneBy(['apiToken' => $token]);
if (null === $user) {
throw new CustomUserMessageAuthenticationException('Invalid API token.');
}
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(
['error' => $exception->getMessageKey()],
Response::HTTP_UNAUTHORIZED
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ClientTicket;
use App\Entity\Notification;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
) {}
/**
* Notify all ROLE_ADMIN users that a new ticket was created.
*/
public function createForTicketCreated(ClientTicket $ticket): void
{
$admins = $this->userRepository->findByRole('ROLE_ADMIN');
$number = sprintf('CT-%03d', $ticket->getNumber());
$projectName = $ticket->getProject()?->getName() ?? '';
foreach ($admins as $admin) {
$notification = new Notification();
$notification->setUser($admin);
$notification->setType('ticket_created');
$notification->setTitle('Nouveau ticket client '.$number);
$notification->setMessage($ticket->getTitle().' — '.$projectName);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
/**
* Notify the ticket submitter that the status has changed.
*/
public function createForStatusChange(ClientTicket $ticket): void
{
$submittedBy = $ticket->getSubmittedBy();
if (null === $submittedBy) {
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusLabel = $ticket->getStatus();
$message = 'Nouveau statut : '.$statusLabel;
if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) {
$message .= ' — '.$ticket->getStatusComment();
}
$notification = new Notification();
$notification->setUser($submittedBy);
$notification->setType('ticket_status_changed');
$notification->setTitle('Ticket '.$number.' mis à jour');
$notification->setMessage($message);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ClientTicket;
use App\Entity\User;
use App\Repository\ClientTicketRepository;
use App\Service\NotificationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private ClientTicketRepository $clientTicketRepository,
private NotificationService $notificationService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$user = $this->security->getUser();
assert($user instanceof User);
$project = $data->getProject();
if (null === $project) {
throw new BadRequestHttpException('Project is required.');
}
// Admins can create tickets on any project; clients only on allowed projects
if (!$this->security->isGranted('ROLE_ADMIN')) {
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can create tickets.');
}
if (!$user->getAllowedProjects()->contains($project)) {
throw new AccessDeniedHttpException('You do not have access to this project.');
}
}
$nextNumber = $this->clientTicketRepository->findNextNumberForProject($project);
$data->setNumber($nextNumber);
$data->setSubmittedBy($user);
$data->setStatus('new');
$data->setCreatedAt(new DateTimeImmutable());
$data->setUpdatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
$this->notificationService->createForTicketCreated($data);
return $data;
}
}

Some files were not shown because too many files have changed in this diff Show More