Compare commits
98 Commits
refactor/d
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e9f9ed65 | ||
|
|
c5898fbf74 | ||
|
|
0180dd3715 | ||
|
|
0f99098291 | ||
|
|
1c6f473dff | ||
|
|
c95fff530c | ||
|
|
fb0e6c1ea4 | ||
|
|
6d3ecc1322 | ||
|
|
f5986090c0 | ||
|
|
d6399c20e1 | ||
|
|
a972d243f5 | ||
|
|
56bf88f293 | ||
| 9d80e017c2 | |||
| 4e91507158 | |||
| 318f14ea88 | |||
| 202b516dc3 | |||
| 98782a9849 | |||
| b978adf9ae | |||
| e4fc34b90f | |||
| a5144443a4 | |||
| afd4baed92 | |||
| e8f0202b15 | |||
| 962b3d935c | |||
| cea22f977b | |||
| 5613a7c92b | |||
| 4d0aa65920 | |||
| 63315c0a15 | |||
| cff16611f4 | |||
| 96f5c7c91c | |||
| f7a76c9e9b | |||
| 7047f64a6b | |||
| cd8cea45c1 | |||
| 1f31a3a33f | |||
| 254f8bc411 | |||
| 239cd6398e | |||
| 318b6198da | |||
| 4e3e854aa2 | |||
| 49cd971e3e | |||
| ffe4a0117c | |||
| d2f6d84d03 | |||
| 2a874046d3 | |||
| f09ef67117 | |||
| 046ee396d3 | |||
| 0ba487cfa9 | |||
| a2fc8e6e52 | |||
| 6c910e7fcc | |||
| 6d7e6f5f48 | |||
| 0c8fb654a9 | |||
| f8748c4061 | |||
| 2c28a4ad1d | |||
| cf1cf1ff5c | |||
| 0724d38a26 | |||
| 17c5160f2c | |||
| 40d6f7693f | |||
| e63ed63dd8 | |||
| ad8142ac9d | |||
| f7afe1c6fb | |||
| 697075eea2 | |||
| 587733e6f9 | |||
| 59b11f1225 | |||
| 4094048aba | |||
| ce2eaa03e1 | |||
| d932359024 | |||
| 669c36cea1 | |||
| 3d1a510d82 | |||
| 68dd9599a9 | |||
| 0d21e59023 | |||
| 7210a0d96f | |||
| 7099f1ca95 | |||
| e16fd2053e | |||
| 760f5b6ad6 | |||
| adf050505d | |||
| 12d043a50f | |||
| bfd418851e | |||
| 4fbbead3e3 | |||
| 64961631e4 | |||
| 7f2371e522 | |||
| 851953df1e | |||
| b6cfe9d7d4 | |||
| f33f2f95ec | |||
| 9a9416d6c8 | |||
| f27297517c | |||
| d2e27a04ce | |||
| 10cde5e2f9 | |||
| 926d6d54c5 | |||
| a538bb3601 | |||
| 97dcff8542 | |||
| 87ab281099 | |||
| 2b9095b1a2 | |||
| 05e24db6ca | |||
| 63febbea45 | |||
| edc441f363 | |||
| f4eec2e6e9 | |||
| 5547c67b30 | |||
| 9e19adc09a | |||
| 8d24949186 | |||
| c2fa308f1e | |||
|
|
4216f1b5a1 |
@@ -45,6 +45,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||||
|
.env \
|
||||||
bin \
|
bin \
|
||||||
config \
|
config \
|
||||||
migrations \
|
migrations \
|
||||||
|
|||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
CLAUDE.md
43
CLAUDE.md
@@ -12,9 +12,14 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration)
|
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument)
|
||||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
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/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
|
||||||
|
src/Service/ # Services métier (NotificationService)
|
||||||
|
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||||
|
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/Repository/ # Repositories Doctrine
|
||||||
src/DataFixtures/ # Fixtures
|
src/DataFixtures/ # Fixtures
|
||||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||||
@@ -23,12 +28,12 @@ migrations/ # Migrations Doctrine
|
|||||||
docs/plans/ # Plans d'implémentation
|
docs/plans/ # Plans d'implémentation
|
||||||
docs/superpowers/ # Plans et specs superpowers
|
docs/superpowers/ # Plans et specs superpowers
|
||||||
frontend/ # App Nuxt 4
|
frontend/ # App Nuxt 4
|
||||||
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||||
frontend/layouts/ # Layouts (pas "layout")
|
frontend/layouts/ # Layouts (default, portal)
|
||||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/)
|
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
|
||||||
frontend/composables/# Composables (useApi, useAppVersion)
|
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
||||||
frontend/services/dto/ # Types TypeScript
|
frontend/services/dto/ # Types TypeScript
|
||||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||||
```
|
```
|
||||||
@@ -70,6 +75,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
||||||
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
||||||
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
||||||
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
|
||||||
|
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
|
||||||
|
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||||
|
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||||
|
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||||
|
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
||||||
|
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -79,9 +91,23 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Middleware global `auth.global.ts` protège les routes
|
- Middleware global `auth.global.ts` protège les routes
|
||||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
|
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
|
||||||
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
|
### 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
|
### Nginx
|
||||||
|
|
||||||
|
- `/_mcp` → Symfony (MCP HTTP transport)
|
||||||
- `/api/*` → Symfony (via try_files + index.php)
|
- `/api/*` → Symfony (via try_files + index.php)
|
||||||
- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check`
|
- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check`
|
||||||
- `/` → SPA frontend (`frontend/dist/`)
|
- `/` → SPA frontend (`frontend/dist/`)
|
||||||
@@ -97,3 +123,6 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
## Fixtures
|
## Fixtures
|
||||||
|
|
||||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||||
|
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||||
|
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
||||||
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
|
|||||||
228
README.md
228
README.md
@@ -1 +1,229 @@
|
|||||||
# Lesstime
|
# Lesstime
|
||||||
|
|
||||||
|
Application de gestion de projet avec suivi du temps et portail client.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Couche | Technologies |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Backend** | PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM |
|
||||||
|
| **Frontend** | Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS |
|
||||||
|
| **Base de données** | PostgreSQL 16 |
|
||||||
|
| **Auth** | JWT HTTP-only cookie (lexik/jwt-authentication-bundle) |
|
||||||
|
| **Infrastructure** | Docker (PHP-FPM, Nginx, PostgreSQL) |
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Gestion de projets et tâches (kanban, groupes, priorités, tags, efforts)
|
||||||
|
- Suivi du temps (timer, calendrier, vue liste)
|
||||||
|
- Portail client avec tickets (bug, amélioration, autre)
|
||||||
|
- Gestion de documents (upload, prévisualisation, téléchargement)
|
||||||
|
- Profil utilisateur avec avatar (crop circulaire)
|
||||||
|
- Notifications temps réel
|
||||||
|
- Intégration Gitea (issues, repos)
|
||||||
|
- Serveur MCP pour assistants IA
|
||||||
|
- Multi-langue (i18n)
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Cloner le repo
|
||||||
|
git clone <url> && cd lesstime
|
||||||
|
|
||||||
|
# 2. Démarrer les containers
|
||||||
|
make start
|
||||||
|
|
||||||
|
# 3. Installation complète (composer, migrations, fixtures, build Nuxt)
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est accessible sur **http://localhost:8082**.
|
||||||
|
|
||||||
|
### Comptes de test (fixtures)
|
||||||
|
|
||||||
|
| Utilisateur | Mot de passe | Rôle | Détails |
|
||||||
|
|-------------|-------------|------|---------|
|
||||||
|
| `admin` | `admin` | ROLE_ADMIN | Administrateur |
|
||||||
|
| `alice` | `alice` | ROLE_USER | Utilisateur interne |
|
||||||
|
| `bob` | `bob` | ROLE_USER | Utilisateur interne |
|
||||||
|
| `charlie` | `charlie` | ROLE_USER | Utilisateur interne |
|
||||||
|
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
||||||
|
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start # Démarrer les containers
|
||||||
|
make stop # Arrêter les containers
|
||||||
|
make restart # Redémarrer les containers
|
||||||
|
make shell # Shell dans le container PHP
|
||||||
|
make shell-root # Shell root dans le container PHP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||||
|
make cache-clear # Vider le cache Symfony
|
||||||
|
make logs-dev # Tail logs Symfony
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migration-migrate # Lancer les migrations
|
||||||
|
make fixtures # Charger les fixtures
|
||||||
|
make db-reset # Reset BDD + migrations + fixtures (⚠️ supprime les données)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests & Qualité
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # PHPUnit
|
||||||
|
make php-cs-fixer-allow-risky # Fix code style PHP (Symfony + PSR-12)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation complète
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # Composer + migrations + fixtures + build Nuxt
|
||||||
|
make reset # Tout supprimer et réinstaller (⚠️ supprime la BDD)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Entity/ # Entités Doctrine
|
||||||
|
├── ApiResource/ # Ressources API Platform (découplées)
|
||||||
|
├── State/ # Providers et Processors API Platform
|
||||||
|
├── Controller/ # Controllers custom Symfony
|
||||||
|
├── Service/ # Services métier
|
||||||
|
├── EventListener/ # Listeners Doctrine
|
||||||
|
├── Exception/ # Exceptions custom
|
||||||
|
├── Security/ # Authenticators custom
|
||||||
|
├── Repository/ # Repositories Doctrine
|
||||||
|
├── Command/ # Commandes console
|
||||||
|
├── DataFixtures/ # Fixtures
|
||||||
|
└── Mcp/Tool/ # MCP tools par domaine
|
||||||
|
├── Project/
|
||||||
|
├── Task/
|
||||||
|
├── TaskMeta/
|
||||||
|
├── TimeEntry/
|
||||||
|
└── Reference/
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── pages/ # Pages Nuxt (routing auto)
|
||||||
|
│ ├── portal/ # Pages portail client
|
||||||
|
│ └── projects/ # Pages projets
|
||||||
|
├── layouts/ # Layouts (default, portal)
|
||||||
|
├── components/ # Composants Vue
|
||||||
|
│ ├── ui/ # Composants génériques
|
||||||
|
│ ├── task/ # Tâches
|
||||||
|
│ ├── user/ # Utilisateur (avatar, etc.)
|
||||||
|
│ ├── project/ # Projets
|
||||||
|
│ ├── client/ # Clients
|
||||||
|
│ ├── client-ticket/ # Tickets client
|
||||||
|
│ ├── admin/ # Administration
|
||||||
|
│ ├── notification/ # Notifications
|
||||||
|
│ └── time-tracking/ # Suivi du temps
|
||||||
|
├── composables/ # Composables (useApi, useNotifications, etc.)
|
||||||
|
├── stores/ # Stores Pinia (auth, ui, timer)
|
||||||
|
├── services/ # Services API
|
||||||
|
│ └── dto/ # Types TypeScript
|
||||||
|
├── plugins/ # Plugins Nuxt
|
||||||
|
├── utils/ # Utilitaires
|
||||||
|
├── i18n/locales/ # Traductions
|
||||||
|
└── middleware/ # Middleware auth
|
||||||
|
|
||||||
|
config/ # Config Symfony
|
||||||
|
migrations/ # Migrations Doctrine
|
||||||
|
docker/ # Dockerfiles et config Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
| Container | Port | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `php-lesstime-fpm` | 3002 (dev Nuxt) | PHP-FPM + Node 24 |
|
||||||
|
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||||
|
| PostgreSQL | 5435 | Base de données |
|
||||||
|
|
||||||
|
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Toutes les routes API sont préfixées `/api` (API Platform).
|
||||||
|
|
||||||
|
- Documentation auto-générée : **http://localhost:8082/api**
|
||||||
|
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
|
||||||
|
|
||||||
|
## Serveur MCP
|
||||||
|
|
||||||
|
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA d'interagir avec les données.
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
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. `cd frontend && npm install && npm run build:dist`
|
||||||
|
6. `docker restart nginx-lesstime`
|
||||||
|
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Propriétaire — Tous droits réservés.
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"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",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
|
|||||||
1037
composer.lock
generated
1037
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
|||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
|
McpBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal 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
23
config/packages/mcp.yaml
Normal 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
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
security:
|
security:
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
@@ -25,6 +28,12 @@ security:
|
|||||||
password_path: password
|
password_path: password
|
||||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||||
|
mcp:
|
||||||
|
pattern: ^/_mcp
|
||||||
|
stateless: true
|
||||||
|
provider: app_user_provider
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\ApiTokenAuthenticator
|
||||||
api:
|
api:
|
||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
@@ -50,6 +59,7 @@ security:
|
|||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
# Version de l'application en public
|
# Version de l'application en public
|
||||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||||
|
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -1610,6 +1610,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
* 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{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1622,6 +1653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1634,6 +1666,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1647,6 +1680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1660,6 +1694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
3
config/routes/mcp.yaml
Normal file
3
config/routes/mcp.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mcp:
|
||||||
|
resource: .
|
||||||
|
type: mcp
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||||
|
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
|
||||||
|
|
||||||
imports:
|
imports:
|
||||||
- { resource: version.yaml }
|
- { resource: version.yaml }
|
||||||
@@ -39,3 +40,7 @@ services:
|
|||||||
App\Controller\TaskDocumentDownloadController:
|
App\Controller\TaskDocumentDownloadController:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
|
App\Controller\UserAvatarController:
|
||||||
|
arguments:
|
||||||
|
$avatarUploadDir: '%avatar_upload_dir%'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.0'
|
app.version: '0.2.2'
|
||||||
|
|||||||
50
deploy/nginx/lesstime.conf
Normal file
50
deploy/nginx/lesstime.conf
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/frontend/.output/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 55m;
|
||||||
|
|
||||||
|
location ^~ /api/ {
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /bundles/ {
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/login_check {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_param PATH_INFO /login_check;
|
||||||
|
fastcgi_param REQUEST_URI /login_check;
|
||||||
|
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /_mcp {
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/index\.php(/|$) {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||||
|
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 55m;
|
client_max_body_size 55m;
|
||||||
|
|
||||||
|
location ^~ /_mcp {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /api/ {
|
location ^~ /api/ {
|
||||||
root /var/www/html/public;
|
root /var/www/html/public;
|
||||||
try_files $uri /index.php?$query_string;
|
try_files $uri /index.php?$query_string;
|
||||||
|
|||||||
213
docs/deploy.md
Normal file
213
docs/deploy.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Deploiement sur serveur Ubuntu (sans Docker)
|
||||||
|
|
||||||
|
## Prerequis
|
||||||
|
|
||||||
|
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
|
||||||
|
- Acces root ou sudo sur le serveur
|
||||||
|
|
||||||
|
## 1. Preparer la BDD
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres createuser lesstime
|
||||||
|
sudo -u postgres createdb -O lesstime lesstime
|
||||||
|
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Creer les dossiers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
|
||||||
|
sudo chown -R www-data:www-data /var/www/lesstime
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Configurer l'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /var/www/lesstime/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Contenu minimal :
|
||||||
|
```ini
|
||||||
|
APP_ENV=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /var/www/lesstime/.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Contenu :
|
||||||
|
```ini
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_SECRET=<random-hex-32>
|
||||||
|
APP_DEBUG=0
|
||||||
|
|
||||||
|
DEFAULT_URI=http://project.malio-dev.fr/
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||||
|
|
||||||
|
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
JWT_PASSPHRASE=<passphrase>
|
||||||
|
JWT_COOKIE_SECURE=0
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
ENCRYPTION_KEY=<random-hex-32>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
|
||||||
|
|
||||||
|
## 4. Installer le script de deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||||
|
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le repo Gitea est prive, configurer un token :
|
||||||
|
```bash
|
||||||
|
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
|
||||||
|
sudo chmod 600 /etc/lesstime-release-token
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Deployer une release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/bin/deploy-lesstime v0.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
|
||||||
|
|
||||||
|
## 6. Generer les cles JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Configurer Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Creer le premier user admin
|
||||||
|
|
||||||
|
Hasher un mot de passe :
|
||||||
|
```bash
|
||||||
|
php /var/www/lesstime/bin/console security:hash-password --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://project.malio-dev.fr/api/version
|
||||||
|
curl http://project.malio-dev.fr/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Connecter le serveur MCP a Claude Code
|
||||||
|
|
||||||
|
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
|
||||||
|
|
||||||
|
## 1. Generer un token API
|
||||||
|
|
||||||
|
Sur le serveur (ou en local via Docker) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production (serveur)
|
||||||
|
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
|
||||||
|
|
||||||
|
# Dev (Docker)
|
||||||
|
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
|
||||||
|
```
|
||||||
|
|
||||||
|
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
|
||||||
|
|
||||||
|
## 2. Configurer Claude Code
|
||||||
|
|
||||||
|
### Transport HTTP (recommande pour la prod)
|
||||||
|
|
||||||
|
Creer ou modifier `.mcp.json` a la racine du projet :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer <ton-token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport STDIO (dev local via Docker)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime-local": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
"php-lesstime-fpm",
|
||||||
|
"php",
|
||||||
|
"bin/console",
|
||||||
|
"mcp:server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport STDIO via SSH (prod sans endpoint HTTP)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"command": "ssh",
|
||||||
|
"args": [
|
||||||
|
"user@serveur",
|
||||||
|
"php",
|
||||||
|
"/var/www/lesstime/bin/console",
|
||||||
|
"mcp:server",
|
||||||
|
"--env=prod"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Redemarrer Claude Code
|
||||||
|
|
||||||
|
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
|
||||||
|
|
||||||
|
## 4. Verifier
|
||||||
|
|
||||||
|
Demander a Claude d'utiliser un outil MCP, par exemple :
|
||||||
|
- "Liste les projets sur Lesstime"
|
||||||
|
- "Cree une tache dans le projet LT"
|
||||||
|
|
||||||
|
## Tools disponibles
|
||||||
|
|
||||||
|
| Domaine | Tools |
|
||||||
|
|---------|-------|
|
||||||
|
| Projets | list-projects, get-project, create-project, update-project |
|
||||||
|
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
|
||||||
|
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
|
||||||
|
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
|
||||||
|
| Reference | list-users, list-clients |
|
||||||
1585
docs/superpowers/plans/2026-03-15-client-portal-phase1.md
Normal file
1585
docs/superpowers/plans/2026-03-15-client-portal-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
1960
docs/superpowers/plans/2026-03-15-client-portal-phase2.md
Normal file
1960
docs/superpowers/plans/2026-03-15-client-portal-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
970
docs/superpowers/plans/2026-03-15-client-portal-phase3.md
Normal file
970
docs/superpowers/plans/2026-03-15-client-portal-phase3.md
Normal 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"
|
||||||
|
```
|
||||||
385
docs/superpowers/plans/2026-03-15-date-filter.md
Normal file
385
docs/superpowers/plans/2026-03-15-date-filter.md
Normal 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"
|
||||||
|
```
|
||||||
2176
docs/superpowers/plans/2026-03-15-mcp-server.md
Normal file
2176
docs/superpowers/plans/2026-03-15-mcp-server.md
Normal file
File diff suppressed because it is too large
Load Diff
802
docs/superpowers/plans/2026-03-15-user-avatar.md
Normal file
802
docs/superpowers/plans/2026-03-15-user-avatar.md
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
# User Avatar 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:** Let users upload a cropped profile avatar that replaces initials everywhere in the app.
|
||||||
|
|
||||||
|
**Architecture:** New `avatarFileName` column on User entity, dedicated upload/serve/delete controllers, `UserAvatar.vue` component with `vue-advanced-cropper` for circular crop, and a `/profile` page for management.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.4/Symfony 8, Doctrine migration, `vue-advanced-cropper`, Nuxt 4 SPA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Backend (create)
|
||||||
|
- `src/Controller/UserAvatarController.php` — upload, serve, delete avatar (3 routes)
|
||||||
|
|
||||||
|
### Backend (modify)
|
||||||
|
- `src/Entity/User.php` — add `avatarFileName` field + `getAvatarUrl()` virtual getter
|
||||||
|
- `config/services.yaml` — add `avatar_upload_dir` parameter + wire controller
|
||||||
|
|
||||||
|
### Frontend (create)
|
||||||
|
- `frontend/components/user/UserAvatar.vue` — reusable avatar display (image or initials fallback)
|
||||||
|
- `frontend/components/user/AvatarCropper.vue` — crop modal using `vue-advanced-cropper`
|
||||||
|
- `frontend/services/avatar.ts` — avatar API service (upload, remove, getUrl)
|
||||||
|
- `frontend/pages/profile.vue` — profile page with avatar management
|
||||||
|
|
||||||
|
### Frontend (modify)
|
||||||
|
- `frontend/services/dto/user-data.ts` — add `avatarUrl` to `UserData`
|
||||||
|
- `frontend/stores/auth.ts` — add `refreshUser()` action
|
||||||
|
- `frontend/components/ui/AppTopNav.vue` — use `UserAvatar` + link "Mon profil" to `/profile`
|
||||||
|
- `frontend/components/task/TaskCard.vue:47-59` — replace initials with `UserAvatar`
|
||||||
|
- `frontend/pages/projects/[id]/archives.vue:49-55` — replace initials with `UserAvatar`
|
||||||
|
- `frontend/components/admin/AdminClientTicketTab.vue:82` — use `UserAvatar` for submitter
|
||||||
|
- `frontend/middleware/auth.global.ts` — allow `/profile` for all authenticated users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — User entity + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/User.php`
|
||||||
|
- Create: migration file (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `avatarFileName` field to User entity**
|
||||||
|
|
||||||
|
In `src/Entity/User.php`, add after the `$apiToken` field:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['me:read', 'user:list'])]
|
||||||
|
private ?string $avatarFileName = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getter/setter:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getAvatarFileName(): ?string
|
||||||
|
{
|
||||||
|
return $this->avatarFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAvatarFileName(?string $avatarFileName): static
|
||||||
|
{
|
||||||
|
$this->avatarFileName = $avatarFileName;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add virtual `avatarUrl` getter (serialized, read-only):
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
|
||||||
|
public function getAvatarUrl(): ?string
|
||||||
|
{
|
||||||
|
if (null === $this->avatarFileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/api/users/' . $this->id . '/avatar';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate and run migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:diff
|
||||||
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/User.php migrations/
|
||||||
|
git commit -m "feat(avatar) : add avatarFileName field to User entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — Avatar controller
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Controller/UserAvatarController.php`
|
||||||
|
- Modify: `config/services.yaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `avatar_upload_dir` parameter in `config/services.yaml`**
|
||||||
|
|
||||||
|
Add to `parameters:` section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add service wiring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
App\Controller\UserAvatarController:
|
||||||
|
arguments:
|
||||||
|
$avatarUploadDir: '%avatar_upload_dir%'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `UserAvatarController.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
class UserAvatarController extends AbstractController
|
||||||
|
{
|
||||||
|
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly string $avatarUploadDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function upload(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
$this->assertCanManageAvatar($user);
|
||||||
|
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
|
||||||
|
if (null === $file || !$file->isValid()) {
|
||||||
|
throw new BadRequestHttpException('No valid file uploaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||||
|
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $file->getClientMimeType();
|
||||||
|
|
||||||
|
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete previous avatar file if exists
|
||||||
|
$this->deleteAvatarFile($user);
|
||||||
|
|
||||||
|
$extension = $file->guessExtension() ?? 'bin';
|
||||||
|
$fileName = Uuid::v4()->toRfc4122() . '.' . $extension;
|
||||||
|
|
||||||
|
if (!is_dir($this->avatarUploadDir)) {
|
||||||
|
mkdir($this->avatarUploadDir, 0o775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file->move($this->avatarUploadDir, $fileName);
|
||||||
|
|
||||||
|
$user->setAvatarFileName($fileName);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function serve(int $id): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
|
||||||
|
if (null === $user->getAvatarFileName()) {
|
||||||
|
throw new NotFoundHttpException('No avatar set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new NotFoundHttpException('Avatar file not found on disk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($filePath);
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
|
||||||
|
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
|
||||||
|
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
|
||||||
|
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
|
||||||
|
$response->headers->set('Cache-Control', 'public, max-age=86400');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function delete(int $id): Response
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
$this->assertCanManageAvatar($user);
|
||||||
|
|
||||||
|
$this->deleteAvatarFile($user);
|
||||||
|
$user->setAvatarFileName(null);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserOrFail(int $id): User
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->find($id);
|
||||||
|
|
||||||
|
if (null === $user) {
|
||||||
|
throw new NotFoundHttpException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCanManageAvatar(User $user): void
|
||||||
|
{
|
||||||
|
$currentUser = $this->getUser();
|
||||||
|
|
||||||
|
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedHttpException('You can only manage your own avatar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteAvatarFile(User $user): void
|
||||||
|
{
|
||||||
|
if (null === $user->getAvatarFileName()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
|
||||||
|
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
unlink($filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/UserAvatarController.php config/services.yaml
|
||||||
|
git commit -m "feat(avatar) : add avatar upload/serve/delete controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — Install vue-advanced-cropper + DTO + service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/dto/user-data.ts`
|
||||||
|
- Create: `frontend/services/avatar.ts`
|
||||||
|
- Modify: `frontend/stores/auth.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install vue-advanced-cropper**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install vue-advanced-cropper
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `UserData` DTO**
|
||||||
|
|
||||||
|
In `frontend/services/dto/user-data.ts`, add `avatarUrl` to `UserData`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type UserData = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
client?: { id: number; name: string } | null
|
||||||
|
allowedProjects?: Project[]
|
||||||
|
avatarUrl?: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `frontend/services/avatar.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useAvatarService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file, 'avatar.png')
|
||||||
|
|
||||||
|
return $fetch(`/api/users/${userId}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(userId: number): Promise<void> {
|
||||||
|
await api.delete(`/users/${userId}/avatar`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrl(userId: number): string {
|
||||||
|
return `/api/users/${userId}/avatar`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upload, remove, getUrl }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `refreshUser` to auth store**
|
||||||
|
|
||||||
|
In `frontend/stores/auth.ts`, add to actions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async refreshUser() {
|
||||||
|
try {
|
||||||
|
const me = await getCurrentUser()
|
||||||
|
this.user = me
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user session might have expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/package.json frontend/package-lock.json frontend/services/dto/user-data.ts frontend/services/avatar.ts frontend/stores/auth.ts
|
||||||
|
git commit -m "feat(avatar) : add avatar service, DTO update, and cropper dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — UserAvatar component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/components/user/UserAvatar.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `UserAvatar.vue`**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||||
|
:class="sizeClasses"
|
||||||
|
:title="user.username"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="user.avatarUrl && !imgError"
|
||||||
|
:src="user.avatarUrl"
|
||||||
|
:alt="user.username"
|
||||||
|
class="h-full w-full rounded-full object-cover"
|
||||||
|
@error="imgError = true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
|
||||||
|
:class="textSizeClass"
|
||||||
|
>
|
||||||
|
{{ user.username.substring(0, 2).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
user: { id?: number; username: string; avatarUrl?: string | null }
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imgError = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.user.avatarUrl, () => {
|
||||||
|
imgError.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeClasses = computed(() => {
|
||||||
|
const map = {
|
||||||
|
xs: 'h-5 w-5',
|
||||||
|
sm: 'h-6 w-6',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
}
|
||||||
|
return map[props.size ?? 'sm']
|
||||||
|
})
|
||||||
|
|
||||||
|
const textSizeClass = computed(() => {
|
||||||
|
const map = {
|
||||||
|
xs: 'text-[10px]',
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
}
|
||||||
|
return map[props.size ?? 'sm']
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/user/UserAvatar.vue
|
||||||
|
git commit -m "feat(avatar) : add UserAvatar component with image/initials fallback"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — AvatarCropper component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/components/user/AvatarCropper.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `AvatarCropper.vue`**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-neutral-900">
|
||||||
|
{{ $t('profile.cropAvatar') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mx-auto mb-4 h-72 w-72">
|
||||||
|
<Cropper
|
||||||
|
ref="cropperRef"
|
||||||
|
:src="imageSrc"
|
||||||
|
:stencil-component="CircleStencil"
|
||||||
|
:stencil-props="{ aspectRatio: 1 }"
|
||||||
|
:canvas="{ width: 256, height: 256 }"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:disabled="cropping"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||||
|
import 'vue-advanced-cropper/dist/style.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageFile: File
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'crop', blob: Blob): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cropperRef = ref()
|
||||||
|
const cropping = ref(false)
|
||||||
|
const imageSrc = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
imageSrc.value = URL.createObjectURL(props.imageFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (imageSrc.value) {
|
||||||
|
URL.revokeObjectURL(imageSrc.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
cropping.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { canvas } = cropperRef.value.getResult()
|
||||||
|
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (blob) {
|
||||||
|
emit('crop', blob)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cropping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/user/AvatarCropper.vue
|
||||||
|
git commit -m "feat(avatar) : add AvatarCropper modal with vue-advanced-cropper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — Profile page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/pages/profile.vue`
|
||||||
|
- Modify: `frontend/middleware/auth.global.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `frontend/pages/profile.vue`**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
|
<!-- Current avatar -->
|
||||||
|
<UserAvatar
|
||||||
|
v-if="auth.user"
|
||||||
|
:user="auth.user"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label
|
||||||
|
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||||
|
>
|
||||||
|
{{ $t('profile.changeAvatar') }}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="auth.user?.avatarUrl"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="onRemove"
|
||||||
|
>
|
||||||
|
{{ $t('profile.removeAvatar') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop modal -->
|
||||||
|
<AvatarCropper
|
||||||
|
v-if="selectedFile"
|
||||||
|
:image-file="selectedFile"
|
||||||
|
@crop="onCrop"
|
||||||
|
@cancel="selectedFile = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const { upload, remove } = useAvatarService()
|
||||||
|
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const removing = ref(false)
|
||||||
|
|
||||||
|
function onFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
selectedFile.value = file
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCrop(blob: Blob) {
|
||||||
|
selectedFile.value = null
|
||||||
|
if (!auth.user) return
|
||||||
|
|
||||||
|
await upload(auth.user.id, blob)
|
||||||
|
await auth.refreshUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
if (!auth.user) return
|
||||||
|
removing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remove(auth.user.id)
|
||||||
|
await auth.refreshUser()
|
||||||
|
} finally {
|
||||||
|
removing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Allow `/profile` for ROLE_CLIENT in middleware**
|
||||||
|
|
||||||
|
In `frontend/middleware/auth.global.ts`, update the client redirect block to also allow `/profile`:
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```typescript
|
||||||
|
if (!isPortalRoute && !isLoginRoute) {
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```typescript
|
||||||
|
const isProfileRoute = to.path === '/profile'
|
||||||
|
if (!isPortalRoute && !isLoginRoute && !isProfileRoute) {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add i18n keys**
|
||||||
|
|
||||||
|
In `frontend/i18n/locales/fr.json`, add under a `"profile"` key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"profile": {
|
||||||
|
"title": "Mon profil",
|
||||||
|
"changeAvatar": "Changer l'avatar",
|
||||||
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
|
"cropAvatar": "Recadrer l'avatar"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/profile.vue frontend/middleware/auth.global.ts frontend/i18n/locales/fr.json
|
||||||
|
git commit -m "feat(avatar) : add profile page with avatar upload and crop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — Replace initials everywhere
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/ui/AppTopNav.vue`
|
||||||
|
- Modify: `frontend/components/task/TaskCard.vue`
|
||||||
|
- Modify: `frontend/pages/projects/[id]/archives.vue`
|
||||||
|
- Modify: `frontend/components/admin/AdminClientTicketTab.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `AppTopNav.vue`**
|
||||||
|
|
||||||
|
Replace the icon + username display (lines 12-14):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||||
|
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Make "Mon profil" button navigate to `/profile`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
|
@click="navigateTo('/profile')"
|
||||||
|
>
|
||||||
|
Mon profil
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `TaskCard.vue`**
|
||||||
|
|
||||||
|
Replace lines 47-59 (the assignee initials span + empty state):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<UserAvatar
|
||||||
|
v-if="task.assignee"
|
||||||
|
:user="task.assignee"
|
||||||
|
size="xs"
|
||||||
|
class="ml-auto"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:account-outline" size="14" />
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `archives.vue`**
|
||||||
|
|
||||||
|
Replace lines 49-55 (the assignee initials span):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<UserAvatar
|
||||||
|
v-if="task.assignee"
|
||||||
|
:user="task.assignee"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `AdminClientTicketTab.vue`**
|
||||||
|
|
||||||
|
Replace the submitter `<td>` at line 82. The `getSubmitterName` function returns a username string. We need to look up the full user to get `avatarUrl`. Modify the function and display:
|
||||||
|
|
||||||
|
Change the `<td>`:
|
||||||
|
```vue
|
||||||
|
<td class="px-3 py-3 text-neutral-600">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="getSubmitterUser(ticket.submittedBy)"
|
||||||
|
:user="getSubmitterUser(ticket.submittedBy)!"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{{ getSubmitterName(ticket.submittedBy) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add helper function:
|
||||||
|
```typescript
|
||||||
|
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||||
|
if (!iri) return undefined
|
||||||
|
const match = iri.match(/\/api\/users\/(\d+)/)
|
||||||
|
if (!match) return undefined
|
||||||
|
const id = Number(match[1])
|
||||||
|
return users.value.find(u => u.id === id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/ui/AppTopNav.vue frontend/components/task/TaskCard.vue frontend/pages/projects/[id]/archives.vue frontend/components/admin/AdminClientTicketTab.vue
|
||||||
|
git commit -m "feat(avatar) : replace initials with UserAvatar component everywhere"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Manual testing
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rebuild and test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-nuxt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test flow**
|
||||||
|
|
||||||
|
1. Login as `admin` / `admin`
|
||||||
|
2. Navigate to profile via header dropdown → "Mon profil"
|
||||||
|
3. Upload an image → verify crop modal appears with circular stencil
|
||||||
|
4. Confirm crop → verify avatar appears on profile page
|
||||||
|
5. Check header — avatar should replace the icon
|
||||||
|
6. Navigate to a project board — assignee cards should show avatar
|
||||||
|
7. Navigate to archives — same check
|
||||||
|
8. Go to admin ticket tab — submitter should show avatar + name
|
||||||
|
9. Remove avatar → verify initials return everywhere
|
||||||
|
10. Login as `client-liot` / `client` → verify profile page accessible from portal
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit if any fixes needed**
|
||||||
86
docs/superpowers/specs/2026-03-15-date-filter-design.md
Normal file
86
docs/superpowers/specs/2026-03-15-date-filter-design.md
Normal 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
|
||||||
495
docs/superpowers/specs/2026-03-15-mcp-server-design.md
Normal file
495
docs/superpowers/specs/2026-03-15-mcp-server-design.md
Normal 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
|
||||||
112
docs/superpowers/specs/2026-03-15-user-avatar-design.md
Normal file
112
docs/superpowers/specs/2026-03-15-user-avatar-design.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# User Avatar — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow users to upload a profile avatar image (with client-side circular crop) that replaces initials everywhere in the app.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Entity Changes
|
||||||
|
|
||||||
|
**User** — add nullable field:
|
||||||
|
- `avatarFileName: ?string` (length 255) — UUID-based filename stored on disk
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- Directory: `var/uploads/avatars/`
|
||||||
|
- Parameter in `services.yaml`: `avatar_upload_dir`
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
All under `/api/users/{id}/avatar`:
|
||||||
|
|
||||||
|
| Method | Description | Auth |
|
||||||
|
|--------|-------------|------|
|
||||||
|
| `POST` | Upload avatar (multipart file) | Owner or ROLE_ADMIN |
|
||||||
|
| `GET` | Serve avatar image (inline) | ROLE_USER or ROLE_CLIENT |
|
||||||
|
| `DELETE` | Remove avatar | Owner or ROLE_ADMIN |
|
||||||
|
|
||||||
|
**POST** accepts a single `file` field. Validates: image MIME (jpeg, png, webp, gif), max 5 MB. Stores with UUID filename, updates `avatarFileName`. Deletes previous file if exists.
|
||||||
|
|
||||||
|
**GET** returns the image with proper `Content-Type`. Returns 404 if no avatar.
|
||||||
|
|
||||||
|
**DELETE** removes file from disk, sets `avatarFileName` to null.
|
||||||
|
|
||||||
|
These are custom Symfony controllers (not API Platform resources) under `/api/` with `priority: 1`.
|
||||||
|
|
||||||
|
### Serialization
|
||||||
|
|
||||||
|
Add a virtual `avatarUrl` field to User serialization (group `user:read`):
|
||||||
|
- If `avatarFileName` is set: `/api/users/{id}/avatar`
|
||||||
|
- If null: `null`
|
||||||
|
|
||||||
|
This way the frontend knows if an avatar exists from any user payload.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- Add `avatar_file_name` column (VARCHAR 255, nullable) to `user` table.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
|
||||||
|
**`UserAvatar.vue`** (`frontend/components/user/UserAvatar.vue`):
|
||||||
|
- Props: `user: { id: number, username: string, avatarUrl?: string | null }`, `size: 'xs' | 'sm' | 'md' | 'lg'`
|
||||||
|
- Sizes: xs=20px, sm=24px, md=32px, lg=48px
|
||||||
|
- If `avatarUrl`: `<img>` rounded-full, object-cover
|
||||||
|
- Else: initials badge (current bg-primary-500 style), 2 first chars of username uppercased
|
||||||
|
- Handles `@error` on img to fallback to initials (broken image)
|
||||||
|
|
||||||
|
**`AvatarCropper.vue`** (`frontend/components/user/AvatarCropper.vue`):
|
||||||
|
- Uses `vue-advanced-cropper` with `CircleStencil`
|
||||||
|
- Props: `imageFile: File`
|
||||||
|
- Emits: `crop(blob: Blob)`, `cancel`
|
||||||
|
- Fixed output size: 256x256px
|
||||||
|
- Modal overlay with crop area + confirm/cancel buttons
|
||||||
|
|
||||||
|
### New Page
|
||||||
|
|
||||||
|
**`/profile`** (`frontend/pages/profile.vue`):
|
||||||
|
- Shows current avatar (large) with "Change" button
|
||||||
|
- File input triggers AvatarCropper modal
|
||||||
|
- On confirm: POST blob to `/api/users/{id}/avatar`
|
||||||
|
- On success: refresh auth store user data
|
||||||
|
- "Remove avatar" button if avatar exists
|
||||||
|
- Accessible from "Mon profil" button in AppTopNav dropdown
|
||||||
|
|
||||||
|
### New Service
|
||||||
|
|
||||||
|
**`frontend/services/avatar.ts`**:
|
||||||
|
- `upload(userId: number, file: Blob): Promise<void>` — POST multipart
|
||||||
|
- `remove(userId: number): Promise<void>` — DELETE
|
||||||
|
- `getUrl(userId: number): string` — returns URL path
|
||||||
|
|
||||||
|
### DTO Update
|
||||||
|
|
||||||
|
**`UserData`** — add: `avatarUrl?: string | null`
|
||||||
|
|
||||||
|
### Replacement Points
|
||||||
|
|
||||||
|
Replace initials/icon with `<UserAvatar>` in:
|
||||||
|
|
||||||
|
| File | Current display | Size |
|
||||||
|
|------|----------------|------|
|
||||||
|
| `TaskCard.vue:48-53` | Initials badge (h-5 w-5) | xs |
|
||||||
|
| `archives.vue:50-55` | Initials badge (h-5 w-5) | xs |
|
||||||
|
| `AppTopNav.vue:13` | `mdi:account-circle-outline` icon | md |
|
||||||
|
| `AdminClientTicketTab.vue` | Username text for submitter | sm |
|
||||||
|
| `ClientTicketDetailModal.vue` | submittedBy display | sm |
|
||||||
|
|
||||||
|
### Auth Store
|
||||||
|
|
||||||
|
After avatar upload/delete, re-fetch current user data so `avatarUrl` updates everywhere reactively.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `vue-advanced-cropper` — npm install in frontend/
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Server-side image processing/resize
|
||||||
|
- Multiple image formats conversion
|
||||||
|
- Avatar for clients (entities), only users
|
||||||
@@ -10,15 +10,13 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<MalioInputText
|
||||||
<MalioInputText
|
v-model="form.tokenId"
|
||||||
v-model="form.tokenId"
|
:label="$t('bookstack.settings.tokenId')"
|
||||||
:label="$t('bookstack.settings.tokenId')"
|
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
input-class="w-full"
|
||||||
input-class="w-full"
|
type="password"
|
||||||
type="password"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
|||||||
384
frontend/components/admin/AdminClientTicketTab.vue
Normal file
384
frontend/components/admin/AdminClientTicketTab.vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<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">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
v-if="getSubmitterUser(ticket.submittedBy)"
|
||||||
|
:user="getSubmitterUser(ticket.submittedBy)!"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{{ getSubmitterName(ticket.submittedBy) }}
|
||||||
|
</div>
|
||||||
|
</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, getAvailableStatusTransitions } = 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 []
|
||||||
|
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 getSubmitterUser(iri: string | null): UserData | undefined {
|
||||||
|
if (!iri) return undefined
|
||||||
|
const match = iri.match(/\/api\/users\/(\d+)/)
|
||||||
|
if (!match) return undefined
|
||||||
|
const id = Number(match[1])
|
||||||
|
return users.value.find(u => u.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
353
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
353
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<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 sub = props.ticket.submittedBy
|
||||||
|
if (!sub) return false
|
||||||
|
// submittedBy can be an IRI string or an embedded object
|
||||||
|
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||||
|
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
327
frontend/components/client-ticket/ProjectClientTickets.vue
Normal file
327
frontend/components/client-ticket/ProjectClientTickets.vue
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<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, getAvailableStatusTransitions } = 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 []
|
||||||
|
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
171
frontend/components/notification/NotificationBell.vue
Normal file
171
frontend/components/notification/NotificationBell.vue
Normal 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>
|
||||||
@@ -117,7 +117,7 @@ async function loadItems() {
|
|||||||
const [g, t, at] = await Promise.all([
|
const [g, t, at] = await Promise.all([
|
||||||
groupService.getByProject(props.projectId),
|
groupService.getByProject(props.projectId),
|
||||||
taskService.getByProject(props.projectId),
|
taskService.getByProject(props.projectId),
|
||||||
taskService.getByProjectArchived(props.projectId),
|
taskService.getByProject(props.projectId, true),
|
||||||
])
|
])
|
||||||
allGroups.value = g
|
allGroups.value = g
|
||||||
activeTasks.value = t
|
activeTasks.value = t
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<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>
|
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -36,13 +44,12 @@
|
|||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
:user="task.assignee"
|
||||||
:title="task.assignee.username"
|
size="xs"
|
||||||
>
|
class="ml-auto"
|
||||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
/>
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
|||||||
@@ -28,12 +28,13 @@
|
|||||||
<!-- File info -->
|
<!-- File info -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||||
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
|
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete button -->
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
|
type="button"
|
||||||
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||||
@click.stop="$emit('delete', doc)"
|
@click.stop="$emit('delete', doc)"
|
||||||
>
|
>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
documents: TaskDocument[]
|
documents: TaskDocument[]
|
||||||
@@ -72,9 +74,4 @@ function getIconForMime(mimeType: string): string {
|
|||||||
return 'heroicons:paper-clip'
|
return 'heroicons:paper-clip'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} o`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
||||||
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
||||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||||
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
|
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
|
||||||
<a
|
<a
|
||||||
:href="downloadUrl"
|
:href="downloadUrl"
|
||||||
download
|
download
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
document: TaskDocument | null
|
document: TaskDocument | null
|
||||||
@@ -98,12 +99,6 @@ const downloadUrl = computed(() => props.document ? getDownloadUrl(props.documen
|
|||||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} o`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus overlay for keyboard events
|
// Focus overlay for keyboard events
|
||||||
watch(() => props.document, (doc) => {
|
watch(() => props.document, (doc) => {
|
||||||
if (doc) {
|
if (doc) {
|
||||||
|
|||||||
@@ -49,14 +49,15 @@
|
|||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId?: number
|
||||||
|
clientTicketId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploaded: []
|
uploaded: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { upload: uploadFile } = useTaskDocumentService()
|
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -109,7 +110,11 @@ async function processFiles(files: File[]) {
|
|||||||
uploads.value.push(state)
|
uploads.value.push(state)
|
||||||
|
|
||||||
try {
|
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.uploading = false
|
||||||
state.progress = 100
|
state.progress = 100
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -35,6 +35,23 @@
|
|||||||
<Icon name="mdi:close" size="20" />
|
<Icon name="mdi:close" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -48,6 +65,20 @@
|
|||||||
@blur="touched.title = true"
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Project select (create mode with project list) -->
|
||||||
|
<div v-if="showProjectSelect" class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.projectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
label="Projet *"
|
||||||
|
empty-option-label="Sélectionner un projet"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||||
|
Le projet est requis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Two-column selects -->
|
<!-- Two-column selects -->
|
||||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -85,6 +116,14 @@
|
|||||||
empty-option-label="Aucun groupe"
|
empty-option-label="Aucun groupe"
|
||||||
min-width="w-full"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
@@ -129,7 +168,7 @@
|
|||||||
/>
|
/>
|
||||||
<TaskDocumentList
|
<TaskDocumentList
|
||||||
v-if="isEditing && task"
|
v-if="isEditing && task"
|
||||||
:documents="documents"
|
:documents="localDocuments"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@delete="handleDeleteDocument"
|
@delete="handleDeleteDocument"
|
||||||
@@ -139,7 +178,7 @@
|
|||||||
<TaskDocumentPreview
|
<TaskDocumentPreview
|
||||||
:document="previewDoc"
|
:document="previewDoc"
|
||||||
:has-prev="previewIndex > 0"
|
:has-prev="previewIndex > 0"
|
||||||
:has-next="previewIndex < documents.length - 1"
|
:has-next="previewIndex < localDocuments.length - 1"
|
||||||
@close="previewDoc = null"
|
@close="previewDoc = null"
|
||||||
@prev="prevPreview"
|
@prev="prevPreview"
|
||||||
@next="nextPreview"
|
@next="nextPreview"
|
||||||
@@ -228,8 +267,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||||
import { useGiteaService } from '~/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { useClientTicketService } from '~/services/client-tickets'
|
||||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
@@ -239,6 +280,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
task: Task | null
|
task: Task | null
|
||||||
@@ -249,6 +292,7 @@ const props = defineProps<{
|
|||||||
tags: TaskTag[]
|
tags: TaskTag[]
|
||||||
groups: TaskGroup[]
|
groups: TaskGroup[]
|
||||||
users: UserData[]
|
users: UserData[]
|
||||||
|
projects?: Project[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -262,6 +306,7 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,10 +334,13 @@ const form = reactive({
|
|||||||
assigneeId: null as number | null,
|
assigneeId: null as number | null,
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
|
clientTicketId: null as number | null,
|
||||||
|
projectId: null as number | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
title: false,
|
title: false,
|
||||||
|
project: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
const statusOptions = computed(() =>
|
||||||
@@ -311,8 +359,22 @@ const userOptions = computed(() =>
|
|||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupOptions = computed(() =>
|
const groupOptions = computed(() => {
|
||||||
props.groups.map(g => ({ label: g.title, value: g.id }))
|
let filtered = props.groups
|
||||||
|
if (showProjectSelect.value && form.projectId) {
|
||||||
|
filtered = filtered.filter(g => g.project?.id === form.projectId)
|
||||||
|
}
|
||||||
|
return filtered.map(g => ({ label: g.title, value: g.id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedProjectId = computed(() =>
|
||||||
|
showProjectSelect.value ? form.projectId : props.projectId
|
||||||
)
|
)
|
||||||
|
|
||||||
const canArchive = computed(() => {
|
const canArchive = computed(() => {
|
||||||
@@ -345,6 +407,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.assigneeId = task.assignee?.id ?? null
|
form.assigneeId = task.assignee?.id ?? null
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
} else {
|
} else {
|
||||||
form.title = ''
|
form.title = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
@@ -354,13 +417,36 @@ function populateForm(task: Task | null) {
|
|||||||
form.assigneeId = null
|
form.assigneeId = null
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
|
form.clientTicketId = null
|
||||||
|
form.projectId = null
|
||||||
}
|
}
|
||||||
touched.title = false
|
touched.title = false
|
||||||
|
touched.project = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
confirmDeleteDocOpen.value = false
|
||||||
|
documentToDelete.value = null
|
||||||
populateForm(props.task)
|
populateForm(props.task)
|
||||||
|
const pid = resolvedProjectId.value
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||||
|
} catch {
|
||||||
|
clientTickets.value = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientTickets.value = []
|
||||||
|
}
|
||||||
|
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||||
|
try {
|
||||||
|
const settings = await getGiteaSettings()
|
||||||
|
giteaUrl.value = settings.url ?? ''
|
||||||
|
} catch {
|
||||||
|
// Gitea not available
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -370,26 +456,46 @@ watch(() => props.task, (task) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, async (open) => {
|
|
||||||
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
|
||||||
try {
|
|
||||||
const settings = await getGiteaSettings()
|
|
||||||
giteaUrl.value = settings.url ?? ''
|
|
||||||
} catch {
|
|
||||||
// Gitea not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { create, update, remove } = useTaskService()
|
const { create, update, remove } = useTaskService()
|
||||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||||
|
const clientTicketService = useClientTicketService()
|
||||||
const { t } = useI18n()
|
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 }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset group and reload client tickets when project changes in create mode
|
||||||
|
watch(() => form.projectId, async (pid) => {
|
||||||
|
if (!showProjectSelect.value) return
|
||||||
|
form.groupId = null
|
||||||
|
form.clientTicketId = null
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||||
|
} catch {
|
||||||
|
clientTickets.value = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientTickets.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
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 localDocuments = ref<TaskDocument[]>([])
|
||||||
const documents = computed(() => localDocuments.value)
|
|
||||||
const previewDoc = ref<TaskDocument | null>(null)
|
const previewDoc = ref<TaskDocument | null>(null)
|
||||||
|
|
||||||
// Sync documents from task prop when modal opens or task changes
|
// Sync documents from task prop when modal opens or task changes
|
||||||
@@ -404,7 +510,7 @@ async function refreshDocuments() {
|
|||||||
|
|
||||||
const previewIndex = computed(() => {
|
const previewIndex = computed(() => {
|
||||||
if (!previewDoc.value) return -1
|
if (!previewDoc.value) return -1
|
||||||
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
|
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
function openPreview(doc: TaskDocument) {
|
function openPreview(doc: TaskDocument) {
|
||||||
@@ -413,13 +519,13 @@ function openPreview(doc: TaskDocument) {
|
|||||||
|
|
||||||
function prevPreview() {
|
function prevPreview() {
|
||||||
if (previewIndex.value > 0) {
|
if (previewIndex.value > 0) {
|
||||||
previewDoc.value = documents.value[previewIndex.value - 1]
|
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPreview() {
|
function nextPreview() {
|
||||||
if (previewIndex.value < documents.value.length - 1) {
|
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||||
previewDoc.value = documents.value[previewIndex.value + 1]
|
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +597,9 @@ async function handleUnarchive() {
|
|||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
touched.title = true
|
touched.title = true
|
||||||
|
touched.project = true
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
|
if (showProjectSelect.value && !form.projectId) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -503,8 +611,9 @@ async function handleSubmit() {
|
|||||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${props.projectId}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
|
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing.value && props.task) {
|
if (isEditing.value && props.task) {
|
||||||
|
|||||||
@@ -7,16 +7,19 @@
|
|||||||
>
|
>
|
||||||
<Icon name="mdi:menu" size="24" />
|
<Icon name="mdi:menu" size="24" />
|
||||||
</button>
|
</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">
|
<div class="group relative flex gap-2 sm:gap-4">
|
||||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||||
|
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||||
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
|
@click="navigateTo('/profile')"
|
||||||
>
|
>
|
||||||
Mon profil
|
{{ $t('profile.title') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -42,7 +45,7 @@ defineProps<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
const handleLogout = async () => {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Teleport v-if="modelValue" to="body">
|
<Teleport v-if="modelValue" to="body">
|
||||||
<Transition name="modal" appear>
|
<Transition name="modal" appear>
|
||||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
|||||||
216
frontend/components/ui/DateFilter.vue
Normal file
216
frontend/components/ui/DateFilter.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<div class="date-filter">
|
||||||
|
<VueDatePicker
|
||||||
|
ref="datepicker"
|
||||||
|
v-model="internalValue"
|
||||||
|
:week-picker="mode === 'week'"
|
||||||
|
:enable-time-picker="false"
|
||||||
|
:locale="frLocale"
|
||||||
|
:format="formatDisplay"
|
||||||
|
auto-apply
|
||||||
|
:multi-calendars="false"
|
||||||
|
position="left"
|
||||||
|
teleport
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
|
||||||
|
<button
|
||||||
|
class="px-2 py-[7px] text-xs font-medium transition"
|
||||||
|
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||||
|
@click.stop="switchMode('day')"
|
||||||
|
>
|
||||||
|
{{ t('common.day') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-[7px] text-xs font-medium transition"
|
||||||
|
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||||
|
@click.stop="switchMode('week')"
|
||||||
|
>
|
||||||
|
{{ t('common.weekShort') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative cursor-pointer">
|
||||||
|
<input
|
||||||
|
:value="displayValue"
|
||||||
|
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||||
|
:placeholder="t('common.dateFilter')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="internalValue"
|
||||||
|
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>
|
||||||
|
</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 datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
|
||||||
|
const mode = ref<'day' | 'week'>('week')
|
||||||
|
const internalValue = ref<Date | Date[] | null>(null)
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (!internalValue.value) return ''
|
||||||
|
if (internalValue.value instanceof Date) {
|
||||||
|
return formatFullDate(internalValue.value)
|
||||||
|
}
|
||||||
|
if (Array.isArray(internalValue.value) && internalValue.value.length >= 2) {
|
||||||
|
const [start, end] = internalValue.value
|
||||||
|
if (!start || !end) return ''
|
||||||
|
return `${formatShortDate(start)} - ${formatShortDate(end)}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDisplay(dates: Date | Date[]): string {
|
||||||
|
if (!dates) return ''
|
||||||
|
if (dates instanceof Date) return formatFullDate(dates)
|
||||||
|
if (!Array.isArray(dates)) return ''
|
||||||
|
const valid = dates.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||||
|
if (valid.length === 0) return ''
|
||||||
|
if (valid.length === 1) return formatFullDate(valid[0])
|
||||||
|
return `${formatShortDate(valid[0])} - ${formatShortDate(valid[1])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFullDate(d: Date): string {
|
||||||
|
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||||
|
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 formatShortDate(d: Date): string {
|
||||||
|
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${day}/${month}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(newMode: 'day' | 'week') {
|
||||||
|
if (mode.value === newMode) return
|
||||||
|
mode.value = newMode
|
||||||
|
internalValue.value = null
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdate(value: Date | Date[] | null) {
|
||||||
|
if (!value) {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode.value === 'week' && Array.isArray(value)) {
|
||||||
|
const valid = value.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||||
|
if (valid.length >= 2) {
|
||||||
|
emit('update:modelValue', [valid[0], valid[1]])
|
||||||
|
}
|
||||||
|
} else if (mode.value === 'day' && value instanceof Date) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClear() {
|
||||||
|
internalValue.value = null
|
||||||
|
datepicker.value?.closeMenu()
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectToday() {
|
||||||
|
mode.value = 'day'
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
internalValue.value = today
|
||||||
|
emit('update:modelValue', today)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectThisWeek() {
|
||||||
|
mode.value = 'week'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}, { 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>
|
||||||
88
frontend/components/user/AvatarCropper.vue
Normal file
88
frontend/components/user/AvatarCropper.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-neutral-900">
|
||||||
|
{{ $t('profile.cropAvatar') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mx-auto mb-4 h-72 w-72">
|
||||||
|
<Cropper
|
||||||
|
ref="cropperRef"
|
||||||
|
:src="imageSrc"
|
||||||
|
:stencil-component="CircleStencil"
|
||||||
|
:stencil-props="{ aspectRatio: 1 }"
|
||||||
|
:canvas="{ width: 256, height: 256 }"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:disabled="cropping"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||||
|
import 'vue-advanced-cropper/dist/style.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageFile: File
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'crop', blob: Blob): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cropperRef = ref()
|
||||||
|
const cropping = ref(false)
|
||||||
|
const imageSrc = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
imageSrc.value = URL.createObjectURL(props.imageFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (imageSrc.value) {
|
||||||
|
URL.revokeObjectURL(imageSrc.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
cropping.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { canvas } = cropperRef.value.getResult()
|
||||||
|
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (blob) {
|
||||||
|
emit('crop', blob)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cropping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
55
frontend/components/user/UserAvatar.vue
Normal file
55
frontend/components/user/UserAvatar.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||||
|
:class="sizeClasses"
|
||||||
|
:title="user.username"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="user.avatarUrl && !imgError"
|
||||||
|
:src="user.avatarUrl"
|
||||||
|
:alt="user.username"
|
||||||
|
class="h-full w-full rounded-full object-cover"
|
||||||
|
@error="imgError = true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
|
||||||
|
:class="textSizeClass"
|
||||||
|
>
|
||||||
|
{{ user.username.substring(0, 2).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
user: { id?: number; username: string; avatarUrl?: string | null }
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imgError = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.user.avatarUrl, () => {
|
||||||
|
imgError.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeClasses = computed(() => {
|
||||||
|
const map = {
|
||||||
|
xs: 'h-5 w-5',
|
||||||
|
sm: 'h-6 w-6',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
}
|
||||||
|
return map[props.size ?? 'sm']
|
||||||
|
})
|
||||||
|
|
||||||
|
const textSizeClass = computed(() => {
|
||||||
|
const map = {
|
||||||
|
xs: 'text-[10px]',
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
}
|
||||||
|
return map[props.size ?? 'sm']
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -36,6 +36,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -52,6 +85,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
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<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -68,15 +105,32 @@ const isOpen = computed({
|
|||||||
set: (v) => emit('update:modelValue', v),
|
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 isEditing = computed(() => !!props.item)
|
||||||
const isSubmitting = ref(false)
|
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 && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
|
clientId: null as number | null,
|
||||||
|
allowedProjectIds: [] as number[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
@@ -84,19 +138,38 @@ const touched = reactive({
|
|||||||
password: false,
|
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 (open) {
|
||||||
if (props.item) {
|
if (props.item) {
|
||||||
form.username = props.item.username ?? ''
|
form.username = props.item.username ?? ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.roles = [...props.item.roles]
|
form.roles = [...props.item.roles]
|
||||||
|
form.clientId = props.item.client?.id ?? null
|
||||||
|
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||||
} else {
|
} else {
|
||||||
form.username = ''
|
form.username = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.roles = ['ROLE_USER']
|
form.roles = ['ROLE_USER']
|
||||||
|
form.clientId = null
|
||||||
|
form.allowedProjectIds = []
|
||||||
}
|
}
|
||||||
touched.username = false
|
touched.username = false
|
||||||
touched.password = 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 = {
|
const payload: UserWrite = {
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
roles: form.roles,
|
roles: form.roles,
|
||||||
|
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||||
|
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
|
||||||
}
|
}
|
||||||
if (form.password) {
|
if (form.password) {
|
||||||
payload.password = form.password
|
payload.password = form.password
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
|||||||
toastSuccessKey?: string
|
toastSuccessKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useApi = (): ApiClient => {
|
let isHandlingUnauthorized = false
|
||||||
|
|
||||||
|
export function useApi(): ApiClient {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const baseURL = config.public.apiBase || '/api'
|
const baseURL = config.public.apiBase || '/api'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
let isHandlingUnauthorized = false
|
|
||||||
const i18n = nuxtApp.$i18n as
|
const i18n = nuxtApp.$i18n as
|
||||||
| {
|
| {
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
@@ -45,7 +46,7 @@ export const useApi = (): ApiClient => {
|
|||||||
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
||||||
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown, responseData?: unknown): string => {
|
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||||
const data = responseData ?? (error as FetchError)?.data
|
const data = responseData ?? (error as FetchError)?.data
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
@@ -169,11 +170,11 @@ export const useApi = (): ApiClient => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const request = <T>(
|
function request<T>(
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||||
url: string,
|
url: string,
|
||||||
options: ApiFetchOptions<'json'> = {}
|
options: ApiFetchOptions<'json'> = {}
|
||||||
) => {
|
) {
|
||||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||||
const needsMergePatch = method === 'PATCH'
|
const needsMergePatch = method === 'PATCH'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export const useAppVersion = () => {
|
export function useAppVersion() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const version = useState<string | null>('app-version', () => null)
|
const version = useState<string | null>('app-version', () => null)
|
||||||
|
|
||||||
const load = async () => {
|
async function load(): Promise<string | null> {
|
||||||
if (version.value) {
|
if (version.value) {
|
||||||
return version.value
|
return version.value
|
||||||
}
|
}
|
||||||
|
|||||||
24
frontend/composables/useAvatarService.ts
Normal file
24
frontend/composables/useAvatarService.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export function useAvatarService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file, 'avatar.png')
|
||||||
|
|
||||||
|
return $fetch(`/api/users/${userId}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(userId: number): Promise<void> {
|
||||||
|
await api.delete(`/users/${userId}/avatar`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrl(userId: number): string {
|
||||||
|
return `/api/users/${userId}/avatar`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upload, remove, getUrl }
|
||||||
|
}
|
||||||
48
frontend/composables/useClientTicketHelpers.ts
Normal file
48
frontend/composables/useClientTicketHelpers.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableStatusTransitions(
|
||||||
|
current: ClientTicketStatus,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): { label: string; value: ClientTicketStatus }[] {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
|
||||||
|
}
|
||||||
69
frontend/composables/useNotifications.ts
Normal file
69
frontend/composables/useNotifications.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,8 @@
|
|||||||
"allEfforts": "Tous les efforts",
|
"allEfforts": "Tous les efforts",
|
||||||
"allAssignees": "Tous",
|
"allAssignees": "Tous",
|
||||||
"noTasks": "Aucune tâche",
|
"noTasks": "Aucune tâche",
|
||||||
"backlog": "Backlog"
|
"backlog": "Backlog",
|
||||||
|
"createTask": "Créer une tâche"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -166,7 +167,15 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"loading": "Chargement..."
|
"save": "Enregistrer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"dateFilter": "Date",
|
||||||
|
"today": "Aujourd'hui",
|
||||||
|
"thisWeek": "Cette semaine",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"day": "Jour",
|
||||||
|
"weekShort": "Sem."
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -213,6 +222,82 @@
|
|||||||
"error": "Erreur de connexion à Gitea.",
|
"error": "Erreur de connexion à Gitea.",
|
||||||
"notConfigured": "Gitea non configuré pour ce projet."
|
"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.",
|
||||||
|
"updated": "Ticket mis à jour 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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Mon profil",
|
||||||
|
"changeAvatar": "Changer l'avatar",
|
||||||
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
|
"cropAvatar": "Recadrer l'avatar"
|
||||||
|
},
|
||||||
"bookstack": {
|
"bookstack": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configuration BookStack",
|
"title": "Configuration BookStack",
|
||||||
|
|||||||
@@ -5,7 +5,3 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { version } = useAppVersion()
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -123,9 +123,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<AppTopNav :user="auth.user" />
|
<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">
|
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden 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" />
|
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
@@ -242,11 +242,6 @@ function onCompleteSaved() {
|
|||||||
timerStore.clearPendingEntry()
|
timerStore.clearPendingEntry()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await auth.logout()
|
|
||||||
await navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
87
frontend/layouts/portal.vue
Normal file
87
frontend/layouts/portal.vue
Normal 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>
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isLogin = to.path === '/login'
|
const isLogin = to.path === '/login'
|
||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
await auth.ensureSession()
|
await auth.ensureSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLogin && !auth.isAuthenticated) {
|
if (!isLogin && !auth.isAuthenticated) {
|
||||||
return navigateTo('/login')
|
return navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLogin && auth.isAuthenticated) {
|
const isClientOnly = auth.isAuthenticated
|
||||||
return navigateTo('/')
|
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||||
}
|
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
|
|
||||||
|
if (isLogin && auth.isAuthenticated) {
|
||||||
|
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProfileRoute = to.path === '/profile'
|
||||||
|
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||||
|
return navigateTo('/portal')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,5 +62,8 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
strict: true
|
strict: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
transpile: ['@vuepic/vue-datepicker']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
178
frontend/package-lock.json
generated
178
frontend/package-lock.json
generated
@@ -12,11 +12,13 @@
|
|||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
|
"vue-advanced-cropper": "^2.8.9",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
}
|
}
|
||||||
@@ -541,6 +543,12 @@
|
|||||||
"postcss-selector-parser": "^7.0.0"
|
"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": {
|
"node_modules/@dxup/nuxt": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
|
||||||
@@ -1094,6 +1102,68 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -5259,6 +5329,12 @@
|
|||||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||||
@@ -5720,6 +5796,62 @@
|
|||||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/abbrev": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||||
@@ -6658,6 +6790,12 @@
|
|||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clipboardy": {
|
"node_modules/clipboardy": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
|
||||||
@@ -7126,6 +7264,16 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||||
@@ -7160,6 +7308,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/debounce": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -7437,6 +7591,12 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/easy-bem": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -13728,6 +13888,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-advanced-cropper": {
|
||||||
|
"version": "2.8.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||||
|
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.2.6",
|
||||||
|
"debounce": "^1.2.0",
|
||||||
|
"easy-bem": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8",
|
||||||
|
"npm": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-bundle-renderer": {
|
"node_modules/vue-bundle-renderer": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
|
"vue-advanced-cropper": "^2.8.9",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
<AdminUserTab v-if="activeTab === 'users'" />
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
|
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
||||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +44,7 @@ const tabs = [
|
|||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
{ key: 'users', label: 'Utilisateurs' },
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
|
{ key: 'client-tickets', label: 'Tickets client' },
|
||||||
{ key: 'gitea', label: 'Gitea' },
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
{ key: 'bookstack', label: 'BookStack' },
|
{ key: 'bookstack', label: 'BookStack' },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ useHead({
|
|||||||
title: 'Connexion'
|
title: 'Connexion'
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
|
|
||||||
@@ -56,14 +55,15 @@ const username = ref('')
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
async function handleSubmit() {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/')
|
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||||
|
await navigateTo(isClient ? '/portal' : '/')
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
|
function openTaskCreate() {
|
||||||
|
selectedTask.value = null
|
||||||
|
taskModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function openTaskEdit(task: Task) {
|
function openTaskEdit(task: Task) {
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
taskModalOpen.value = true
|
taskModalOpen.value = true
|
||||||
@@ -229,28 +234,37 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<!-- Header + Filters -->
|
<!-- Header + Filters -->
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<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="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||||
<div class="flex gap-1">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="rounded-lg p-2 transition-colors"
|
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
@click="openTaskCreate"
|
||||||
:title="$t('myTasks.viewKanban')"
|
|
||||||
@click="viewMode = 'kanban'"
|
|
||||||
>
|
>
|
||||||
<Icon name="mdi:view-column-outline" size="20" />
|
<Icon name="mdi:plus" size="18" />
|
||||||
</button>
|
{{ $t('myTasks.createTask') }}
|
||||||
<button
|
|
||||||
class="rounded-lg p-2 transition-colors"
|
|
||||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
|
||||||
:title="$t('myTasks.viewList')"
|
|
||||||
@click="viewMode = 'list'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:view-list-outline" size="20" />
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||||
|
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||||
|
:title="$t('myTasks.viewKanban')"
|
||||||
|
@click="viewMode = 'kanban'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:view-column-outline" size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||||
|
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||||
|
:title="$t('myTasks.viewList')"
|
||||||
|
@click="viewMode = 'list'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:view-list-outline" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -314,11 +328,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Kanban View -->
|
<!-- Kanban View -->
|
||||||
<div v-if="viewMode === 'kanban'">
|
<div v-if="viewMode === 'kanban'">
|
||||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
<div class="mt-6 flex gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="status in sortedStatuses"
|
v-for="status in sortedStatuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@dragenter.prevent="onDragEnter(status.id)"
|
@dragenter.prevent="onDragEnter(status.id)"
|
||||||
@@ -411,12 +425,20 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<div class="flex items-center gap-1.5">
|
||||||
v-if="task.project && task.number"
|
<Icon
|
||||||
class="text-sm font-medium text-primary-500"
|
v-if="task.clientTicket"
|
||||||
>
|
name="heroicons:user-circle"
|
||||||
{{ task.project.code }}-{{ task.number }}
|
class="h-4 w-4 text-blue-400"
|
||||||
</span>
|
: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>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -438,6 +460,7 @@ onMounted(() => {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||||
:users="users"
|
:users="users"
|
||||||
|
:projects="projects"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
86
frontend/pages/portal/index.vue
Normal file
86
frontend/pages/portal/index.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<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')) {
|
||||||
|
projects.value = await projectService.getAll({ archived: false })
|
||||||
|
} else {
|
||||||
|
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||||
|
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets.value = await clientTicketService.getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
284
frontend/pages/portal/projects/[id]/index.vue
Normal file
284
frontend/pages/portal/projects/[id]/index.vue
Normal 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>
|
||||||
134
frontend/pages/portal/projects/[id]/new-ticket.vue
Normal file
134
frontend/pages/portal/projects/[id]/new-ticket.vue
Normal 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>
|
||||||
91
frontend/pages/profile.vue
Normal file
91
frontend/pages/profile.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
|
<!-- Current avatar -->
|
||||||
|
<UserAvatar
|
||||||
|
v-if="auth.user"
|
||||||
|
:user="auth.user"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label
|
||||||
|
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||||
|
>
|
||||||
|
{{ $t('profile.changeAvatar') }}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="auth.user?.avatarUrl"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="onRemove"
|
||||||
|
>
|
||||||
|
{{ $t('profile.removeAvatar') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop modal -->
|
||||||
|
<AvatarCropper
|
||||||
|
v-if="selectedFile"
|
||||||
|
:image-file="selectedFile"
|
||||||
|
@crop="onCrop"
|
||||||
|
@cancel="selectedFile = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const { upload, remove } = useAvatarService()
|
||||||
|
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const removing = ref(false)
|
||||||
|
|
||||||
|
function onFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
selectedFile.value = file
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCrop(blob: Blob) {
|
||||||
|
selectedFile.value = null
|
||||||
|
if (!auth.user) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upload(auth.user.id, blob)
|
||||||
|
await auth.refreshUser()
|
||||||
|
} catch {
|
||||||
|
// Upload error — $fetch will throw on non-2xx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
if (!auth.user) return
|
||||||
|
removing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remove(auth.user.id)
|
||||||
|
await auth.refreshUser()
|
||||||
|
} finally {
|
||||||
|
removing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -46,13 +46,11 @@
|
|||||||
>
|
>
|
||||||
{{ task.group.title }}
|
{{ task.group.title }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
:user="task.assignee"
|
||||||
:title="task.assignee.username"
|
size="xs"
|
||||||
>
|
/>
|
||||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +128,7 @@ const filteredTasks = computed(() => {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||||
projectService.getById(projectId.value),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProjectArchived(projectId.value),
|
taskService.getByProject(projectId.value, true),
|
||||||
statusService.getAll(),
|
statusService.getAll(),
|
||||||
effortService.getAll(),
|
effortService.getAll(),
|
||||||
priorityService.getAll(),
|
priorityService.getAll(),
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<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="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
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"
|
<button
|
||||||
@click="openTaskCreate"
|
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>
|
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
@@ -53,11 +62,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kanban -->
|
<!-- Kanban -->
|
||||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
<div class="mt-6 flex gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="status in statuses"
|
v-for="status in statuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@dragenter.prevent="onDragEnter(status.id)"
|
@dragenter.prevent="onDragEnter(status.id)"
|
||||||
@@ -120,6 +129,13 @@
|
|||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectDrawer
|
||||||
|
v-model="projectDrawerOpen"
|
||||||
|
:project="project"
|
||||||
|
:clients="clients"
|
||||||
|
@saved="onProjectSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
|
|||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useClientService } from '~/services/clients'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
|
|||||||
useHead({ title: 'Projet' })
|
useHead({ title: 'Projet' })
|
||||||
|
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
|
const clientService = useClientService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
const statusService = useTaskStatusService()
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
@@ -163,6 +182,7 @@ const priorities = ref<TaskPriority[]>([])
|
|||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
const groups = ref<TaskGroup[]>([])
|
const groups = ref<TaskGroup[]>([])
|
||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
const selectedGroupId = ref<number | null>(null)
|
const selectedGroupId = ref<number | null>(null)
|
||||||
@@ -172,6 +192,7 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const taskDrawerOpen = ref(false)
|
const taskDrawerOpen = ref(false)
|
||||||
|
const projectDrawerOpen = ref(false)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
|
||||||
const groupFilterOptions = computed(() =>
|
const groupFilterOptions = computed(() =>
|
||||||
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value),
|
taskService.getByProject(projectId.value),
|
||||||
statusService.getAll(),
|
statusService.getAll(),
|
||||||
@@ -227,6 +248,7 @@ async function loadData() {
|
|||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
groupService.getByProject(projectId.value),
|
groupService.getByProject(projectId.value),
|
||||||
userService.getAll(),
|
userService.getAll(),
|
||||||
|
clientService.getAll(),
|
||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
tasks.value = t
|
tasks.value = t
|
||||||
@@ -236,6 +258,7 @@ async function loadData() {
|
|||||||
tags.value = ty
|
tags.value = ty
|
||||||
groups.value = g
|
groups.value = g
|
||||||
users.value = u
|
users.value = u
|
||||||
|
clients.value = c
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -290,6 +313,10 @@ async function onSaved() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onProjectSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,10 +70,12 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DateFilter v-model="selectedDateFilter" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 -mb-24 min-h-0 flex-1">
|
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||||
<TimeEntryList
|
<TimeEntryList
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
:entries="filteredEntries"
|
:entries="filteredEntries"
|
||||||
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
|
|||||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedProjectId = ref<number | null>(null)
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||||
|
|
||||||
const entries = ref<TimeEntry[]>([])
|
const entries = ref<TimeEntry[]>([])
|
||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
@@ -281,24 +284,10 @@ async function onPaste() {
|
|||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updatePageHeaderHeight()
|
|
||||||
|
|
||||||
if (!pageHeaderEl.value || typeof ResizeObserver === 'undefined') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pageHeaderResizeObserver = new ResizeObserver(() => {
|
|
||||||
updatePageHeaderHeight()
|
|
||||||
})
|
|
||||||
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
pageHeaderResizeObserver?.disconnect()
|
pageHeaderResizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async function onDelete(entry: TimeEntry) {
|
async function onDelete(entry: TimeEntry) {
|
||||||
await timeEntryService.remove(entry.id)
|
await timeEntryService.remove(entry.id)
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
@@ -330,6 +319,15 @@ async function loadReferenceData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
updatePageHeaderHeight()
|
||||||
|
|
||||||
|
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||||
|
updatePageHeaderHeight()
|
||||||
|
})
|
||||||
|
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||||
|
}
|
||||||
|
|
||||||
await loadReferenceData()
|
await loadReferenceData()
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
})
|
})
|
||||||
@@ -342,4 +340,16 @@ watch(viewMode, () => {
|
|||||||
watch(selectedUserId, () => {
|
watch(selectedUserId, () => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(selectedDateFilter, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
startDate.value = getMonday(val[0])
|
||||||
|
viewMode.value = 'week'
|
||||||
|
} else {
|
||||||
|
startDate.value = val
|
||||||
|
viewMode.value = 'day'
|
||||||
|
}
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import type { UserData } from './dto/user-data'
|
import type { UserData } from './dto/user-data'
|
||||||
|
|
||||||
export const getCurrentUser = () => {
|
export function getCurrentUser() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
|
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const login = (username: string, password: string) => {
|
export function login(username: string, password: string) {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post('/login_check', { username, password }, {
|
return api.post('/login_check', { username, password }, {
|
||||||
toastOn401: true,
|
toastOn401: true,
|
||||||
toastErrorKey: 'errors.auth.login'
|
toastErrorKey: 'errors.auth.login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logout = () => {
|
export function logout() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post('/logout', {}, {
|
return api.post('/logout', {}, {
|
||||||
toastErrorKey: 'errors.auth.logout',
|
toastErrorKey: 'errors.auth.logout',
|
||||||
toastSuccessKey: 'success.auth.logout'
|
toastSuccessKey: 'success.auth.logout'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
46
frontend/services/client-tickets.ts
Normal file
46
frontend/services/client-tickets.ts
Normal 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.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/client_tickets/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'clientTicket.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getById, create, update, updateStatus, remove }
|
||||||
|
}
|
||||||
34
frontend/services/dto/client-ticket.ts
Normal file
34
frontend/services/dto/client-ticket.ts
Normal 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
|
||||||
|
}
|
||||||
13
frontend/services/dto/notification.ts
Normal file
13
frontend/services/dto/notification.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@ export type Task = {
|
|||||||
tags: TaskTag[]
|
tags: TaskTag[]
|
||||||
documents: TaskDocument[]
|
documents: TaskDocument[]
|
||||||
archived: boolean
|
archived: boolean
|
||||||
|
clientTicket: {
|
||||||
|
id: number
|
||||||
|
number: number
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
title: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskWrite = {
|
export type TaskWrite = {
|
||||||
@@ -35,4 +42,5 @@ export type TaskWrite = {
|
|||||||
project: string
|
project: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
|
clientTicket?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import type { Project } from './project'
|
||||||
|
|
||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
username: string
|
username: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
client?: { id: number; name: string } | null
|
||||||
|
allowedProjects?: Project[]
|
||||||
|
avatarUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWrite = {
|
export type UserWrite = {
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
client?: string | null
|
||||||
|
allowedProjects?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
33
frontend/services/notifications.ts
Normal file
33
frontend/services/notifications.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -15,19 +15,33 @@ export function useTaskDocumentService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(taskId: number, file: File): Promise<TaskDocument> {
|
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
formData.append('task', `/api/tasks/${taskId}`)
|
formData.append(relationField, relationIri)
|
||||||
|
|
||||||
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
// Do NOT set Content-Type — browser sets multipart boundary automatically
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upload(taskId: number, file: File): Promise<TaskDocument> {
|
||||||
|
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||||
|
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/task_documents/${id}`, {}, {
|
await api.delete(`/task_documents/${id}`, {}, {
|
||||||
toastSuccessKey: 'taskDocuments.deleted',
|
toastSuccessKey: 'taskDocuments.deleted',
|
||||||
@@ -38,5 +52,5 @@ export function useTaskDocumentService() {
|
|||||||
return `${baseURL}/task_documents/${id}/download`
|
return `${baseURL}/task_documents/${id}/download`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getByTask, upload, remove, getDownloadUrl }
|
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,10 @@ export function useTaskService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getByProject(projectId: number): Promise<Task[]> {
|
async function getByProject(projectId: number, archived = false): Promise<Task[]> {
|
||||||
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
||||||
project: `/api/projects/${projectId}`,
|
project: `/api/projects/${projectId}`,
|
||||||
archived: false,
|
archived,
|
||||||
})
|
|
||||||
return extractHydraMembers(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getByProjectArchived(projectId: number): Promise<Task[]> {
|
|
||||||
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
|
||||||
project: `/api/projects/${projectId}`,
|
|
||||||
archived: true,
|
|
||||||
})
|
})
|
||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
@@ -49,5 +41,5 @@ export function useTaskService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
|
return { getAll, getByProject, getFiltered, create, update, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.checked = true
|
this.checked = true
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async refreshUser() {
|
||||||
|
try {
|
||||||
|
const me = await getCurrentUser()
|
||||||
|
this.user = me
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user session might have expired
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export const useTimerStore = defineStore('timer', () => {
|
|||||||
startTicking()
|
startTicking()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toIri<T extends { '@id'?: string; id: number }>(entity: T | string, prefix: string): string {
|
||||||
|
if (typeof entity === 'string') return entity
|
||||||
|
return entity['@id'] ?? `${prefix}/${entity.id}`
|
||||||
|
}
|
||||||
|
|
||||||
async function startFromTask(task: Task) {
|
async function startFromTask(task: Task) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (!authStore.user) return
|
if (!authStore.user) return
|
||||||
@@ -79,11 +84,9 @@ export const useTimerStore = defineStore('timer', () => {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
user: `/api/users/${authStore.user.id}`,
|
user: `/api/users/${authStore.user.id}`,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
project: task.project
|
project: task.project ? toIri(task.project, '/api/projects') : null,
|
||||||
? (typeof task.project === 'string' ? task.project : (task.project['@id'] ?? (task.project.id ? `/api/projects/${task.project.id}` : null)))
|
task: toIri(task, '/api/tasks'),
|
||||||
: null,
|
tags: task.tags?.map(t => toIri(t, '/api/task_tags')) ?? [],
|
||||||
task: typeof task === 'string' ? task : (task['@id'] ?? `/api/tasks/${task.id}`),
|
|
||||||
tags: task.tags?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_tags/${t.id}`)) ?? [],
|
|
||||||
})
|
})
|
||||||
startTicking()
|
startTicking()
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/utils/format.ts
Normal file
5
frontend/utils/format.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} o`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||||
|
}
|
||||||
69
migrations/Version20260315182512.php
Normal file
69
migrations/Version20260315182512.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20260315183313.php
Normal file
33
migrations/Version20260315183313.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
migrations/Version20260315184538.php
Normal file
38
migrations/Version20260315184538.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260315205331.php
Normal file
31
migrations/Version20260315205331.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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 Version20260315205331 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 avatar_file_name VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP avatar_file_name');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260315210619.php
Normal file
31
migrations/Version20260315210619.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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 Version20260315210619 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 UNIQUE INDEX uniq_task_project_number ON task (project_id, number)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP INDEX uniq_task_project_number');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260316124157.php
Normal file
35
migrations/Version20260316124157.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260316124157 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 time_entry ADD client_ticket_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
|
||||||
|
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
|
||||||
|
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
91
script/deploy-release.sh
Executable file
91
script/deploy-release.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage: ./script/deploy-release.sh v0.1.0
|
||||||
|
# Requires: curl, tar, (optional) rsync
|
||||||
|
#
|
||||||
|
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
|
||||||
|
umask 002
|
||||||
|
|
||||||
|
TAG="${1:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "Usage: $0 v0.1.0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_OWNER="MALIO-DEV"
|
||||||
|
REPO_NAME="Lesstime"
|
||||||
|
GITEA_API="https://gitea.malio.fr/api/v1"
|
||||||
|
DEPLOY_DIR="/var/www/lesstime"
|
||||||
|
|
||||||
|
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
release_json="$tmp_dir/release.json"
|
||||||
|
curl_opts=(-sS)
|
||||||
|
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||||
|
fi
|
||||||
|
curl "${curl_opts[@]}" \
|
||||||
|
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
||||||
|
-o "$release_json"
|
||||||
|
|
||||||
|
asset_url="$(python3 - "$release_json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
data = json.load(open(sys.argv[1], 'r'))
|
||||||
|
assets = data.get("assets", [])
|
||||||
|
for a in assets:
|
||||||
|
name = a.get("name", "")
|
||||||
|
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
|
||||||
|
print(a.get("browser_download_url", ""))
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$asset_url" ]; then
|
||||||
|
echo "Release asset not found for tag ${TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive="$tmp_dir/artefact.tar.gz"
|
||||||
|
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
||||||
|
|
||||||
|
tar -xzf "$archive" -C "$tmp_dir"
|
||||||
|
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a --delete --no-perms --no-owner --no-group \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude ".env.local" \
|
||||||
|
--exclude "config/jwt" \
|
||||||
|
--exclude "var" \
|
||||||
|
"$tmp_dir"/ "$DEPLOY_DIR"/
|
||||||
|
else
|
||||||
|
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure Nginx can traverse the deploy path.
|
||||||
|
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
|
||||||
|
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
|
||||||
|
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
||||||
|
echo "Clearing cache..."
|
||||||
|
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
||||||
|
|
||||||
|
echo "Running migrations (if any)..."
|
||||||
|
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
||||||
|
else
|
||||||
|
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
|
||||||
|
fi
|
||||||
58
src/Command/GenerateApiTokenCommand.php
Normal file
58
src/Command/GenerateApiTokenCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Controller/MarkAllReadController.php
Normal file
31
src/Controller/MarkAllReadController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Controller/NotificationUnreadCountController.php
Normal file
31
src/Controller/NotificationUnreadCountController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ namespace App\Controller;
|
|||||||
use App\Entity\TaskDocument;
|
use App\Entity\TaskDocument;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -17,11 +19,12 @@ class TaskDocumentDownloadController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
private readonly string $uploadDir,
|
private readonly string $uploadDir,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
|
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function __invoke(int $id): BinaryFileResponse
|
public function __invoke(int $id): BinaryFileResponse
|
||||||
{
|
{
|
||||||
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
|
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
|
||||||
@@ -30,6 +33,14 @@ class TaskDocumentDownloadController extends AbstractController
|
|||||||
throw new NotFoundHttpException('Document not found.');
|
throw new NotFoundHttpException('Document not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ROLE_CLIENT can only download documents from their own tickets
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_USER')) {
|
||||||
|
$ticket = $document->getClientTicket();
|
||||||
|
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
|
||||||
|
throw new AccessDeniedHttpException('You do not have access to this document.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||||
|
|
||||||
if (!file_exists($filePath)) {
|
if (!file_exists($filePath)) {
|
||||||
|
|||||||
145
src/Controller/UserAvatarController.php
Normal file
145
src/Controller/UserAvatarController.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
class UserAvatarController extends AbstractController
|
||||||
|
{
|
||||||
|
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly string $avatarUploadDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function upload(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
$this->assertCanManageAvatar($user);
|
||||||
|
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
|
||||||
|
if (null === $file || !$file->isValid()) {
|
||||||
|
throw new BadRequestHttpException('No valid file uploaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||||
|
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $file->getMimeType();
|
||||||
|
|
||||||
|
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete previous avatar file if exists
|
||||||
|
$this->deleteAvatarFile($user);
|
||||||
|
|
||||||
|
$extension = $file->guessExtension() ?? 'bin';
|
||||||
|
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
|
||||||
|
|
||||||
|
if (!is_dir($this->avatarUploadDir)) {
|
||||||
|
mkdir($this->avatarUploadDir, 0o775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file->move($this->avatarUploadDir, $fileName);
|
||||||
|
|
||||||
|
$user->setAvatarFileName($fileName);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function serve(int $id): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
|
||||||
|
if (null === $user->getAvatarFileName()) {
|
||||||
|
throw new NotFoundHttpException('No avatar set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new NotFoundHttpException('Avatar file not found on disk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($filePath);
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
|
||||||
|
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
|
||||||
|
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
|
||||||
|
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function delete(int $id): Response
|
||||||
|
{
|
||||||
|
$user = $this->findUserOrFail($id);
|
||||||
|
$this->assertCanManageAvatar($user);
|
||||||
|
|
||||||
|
$this->deleteAvatarFile($user);
|
||||||
|
$user->setAvatarFileName(null);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserOrFail(int $id): User
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->find($id);
|
||||||
|
|
||||||
|
if (null === $user) {
|
||||||
|
throw new NotFoundHttpException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCanManageAvatar(User $user): void
|
||||||
|
{
|
||||||
|
$currentUser = $this->getUser();
|
||||||
|
|
||||||
|
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedHttpException('You can only manage your own avatar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteAvatarFile(User $user): void
|
||||||
|
{
|
||||||
|
if (null === $user->getAvatarFileName()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
|
||||||
|
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
unlink($filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\DataFixtures;
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
use App\Entity\Client;
|
use App\Entity\Client;
|
||||||
|
use App\Entity\ClientTicket;
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
use App\Entity\TaskEffort;
|
use App\Entity\TaskEffort;
|
||||||
@@ -28,13 +29,32 @@ class AppFixtures extends Fixture
|
|||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
// User admin
|
// Users
|
||||||
$admin = new User();
|
$admin = new User();
|
||||||
$admin->setUsername('admin');
|
$admin->setUsername('admin');
|
||||||
$admin->setRoles(['ROLE_ADMIN']);
|
$admin->setRoles(['ROLE_ADMIN']);
|
||||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||||
|
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
|
||||||
$manager->persist($admin);
|
$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
|
// Clients
|
||||||
$clientLiot = new Client();
|
$clientLiot = new Client();
|
||||||
$clientLiot->setName('LIOT');
|
$clientLiot->setName('LIOT');
|
||||||
@@ -250,7 +270,7 @@ class AppFixtures extends Fixture
|
|||||||
$task2->setStatus($statusTodo);
|
$task2->setStatus($statusTodo);
|
||||||
$task2->setEffort($effortL);
|
$task2->setEffort($effortL);
|
||||||
$task2->setPriority($priorityHigh);
|
$task2->setPriority($priorityHigh);
|
||||||
$task2->setAssignee($admin);
|
$task2->setAssignee($userAlice);
|
||||||
$task2->setGroup($groupFrontend);
|
$task2->setGroup($groupFrontend);
|
||||||
$task2->setProject($projectSirh);
|
$task2->setProject($projectSirh);
|
||||||
$task2->addTag($tagAuth);
|
$task2->addTag($tagAuth);
|
||||||
@@ -274,7 +294,7 @@ class AppFixtures extends Fixture
|
|||||||
$task4->setStatus($statusBlocked);
|
$task4->setStatus($statusBlocked);
|
||||||
$task4->setEffort($effortXXL);
|
$task4->setEffort($effortXXL);
|
||||||
$task4->setPriority($priorityLow);
|
$task4->setPriority($priorityLow);
|
||||||
$task4->setAssignee($admin);
|
$task4->setAssignee($userBob);
|
||||||
$task4->setProject($projectSirh);
|
$task4->setProject($projectSirh);
|
||||||
$task4->addTag($tagPassword);
|
$task4->addTag($tagPassword);
|
||||||
$manager->persist($task4);
|
$manager->persist($task4);
|
||||||
@@ -285,7 +305,7 @@ class AppFixtures extends Fixture
|
|||||||
$task5->setStatus($statusReview);
|
$task5->setStatus($statusReview);
|
||||||
$task5->setEffort($effortXXL);
|
$task5->setEffort($effortXXL);
|
||||||
$task5->setPriority($priorityMedium);
|
$task5->setPriority($priorityMedium);
|
||||||
$task5->setAssignee($admin);
|
$task5->setAssignee($userCharlie);
|
||||||
$task5->setProject($projectSirh);
|
$task5->setProject($projectSirh);
|
||||||
$task5->addTag($tagCalendar);
|
$task5->addTag($tagCalendar);
|
||||||
$manager->persist($task5);
|
$manager->persist($task5);
|
||||||
@@ -321,7 +341,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskCrm2->setStatus($statusInProgress);
|
$taskCrm2->setStatus($statusInProgress);
|
||||||
$taskCrm2->setEffort($effortM);
|
$taskCrm2->setEffort($effortM);
|
||||||
$taskCrm2->setPriority($priorityMedium);
|
$taskCrm2->setPriority($priorityMedium);
|
||||||
$taskCrm2->setAssignee($admin);
|
$taskCrm2->setAssignee($userAlice);
|
||||||
$taskCrm2->setGroup($groupCrmUi);
|
$taskCrm2->setGroup($groupCrmUi);
|
||||||
$taskCrm2->setProject($projectCrm);
|
$taskCrm2->setProject($projectCrm);
|
||||||
$manager->persist($taskCrm2);
|
$manager->persist($taskCrm2);
|
||||||
@@ -343,7 +363,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskCrm4->setStatus($statusInProgress);
|
$taskCrm4->setStatus($statusInProgress);
|
||||||
$taskCrm4->setEffort($effortXXL);
|
$taskCrm4->setEffort($effortXXL);
|
||||||
$taskCrm4->setPriority($priorityHigh);
|
$taskCrm4->setPriority($priorityHigh);
|
||||||
$taskCrm4->setAssignee($admin);
|
$taskCrm4->setAssignee($userBob);
|
||||||
$taskCrm4->setGroup($groupCrmUi);
|
$taskCrm4->setGroup($groupCrmUi);
|
||||||
$taskCrm4->setProject($projectCrm);
|
$taskCrm4->setProject($projectCrm);
|
||||||
$taskCrm4->addTag($tagCalendar);
|
$taskCrm4->addTag($tagCalendar);
|
||||||
@@ -380,7 +400,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskErp2->setStatus($statusInProgress);
|
$taskErp2->setStatus($statusInProgress);
|
||||||
$taskErp2->setEffort($effortM);
|
$taskErp2->setEffort($effortM);
|
||||||
$taskErp2->setPriority($priorityHigh);
|
$taskErp2->setPriority($priorityHigh);
|
||||||
$taskErp2->setAssignee($admin);
|
$taskErp2->setAssignee($userCharlie);
|
||||||
$taskErp2->setGroup($groupErpStock);
|
$taskErp2->setGroup($groupErpStock);
|
||||||
$taskErp2->setProject($projectErp);
|
$taskErp2->setProject($projectErp);
|
||||||
$manager->persist($taskErp2);
|
$manager->persist($taskErp2);
|
||||||
@@ -450,7 +470,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskSite2->setStatus($statusInProgress);
|
$taskSite2->setStatus($statusInProgress);
|
||||||
$taskSite2->setEffort($effortL);
|
$taskSite2->setEffort($effortL);
|
||||||
$taskSite2->setPriority($priorityMedium);
|
$taskSite2->setPriority($priorityMedium);
|
||||||
$taskSite2->setAssignee($admin);
|
$taskSite2->setAssignee($userAlice);
|
||||||
$taskSite2->setGroup($groupSiteDesign);
|
$taskSite2->setGroup($groupSiteDesign);
|
||||||
$taskSite2->setProject($projectInterne);
|
$taskSite2->setProject($projectInterne);
|
||||||
$manager->persist($taskSite2);
|
$manager->persist($taskSite2);
|
||||||
@@ -542,6 +562,94 @@ class AppFixtures extends Fixture
|
|||||||
$manager->persist($entry);
|
$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();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
|||||||
261
src/Entity/ClientTicket.php
Normal file
261
src/Entity/ClientTicket.php
Normal 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
160
src/Entity/Notification.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
||||||
@@ -41,7 +41,7 @@ class Project
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['project:read', 'time_entry:read', 'task:read'])]
|
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 10, unique: true)]
|
#[ORM\Column(length: 10, unique: true)]
|
||||||
@@ -51,7 +51,7 @@ class Project
|
|||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
|
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read'])]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(paginationEnabled: false),
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
@@ -35,6 +35,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||||
|
#[ORM\Table(name: 'task')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
|
||||||
class Task
|
class Task
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
@@ -104,6 +106,11 @@ class Task
|
|||||||
#[Groups(['task:read'])]
|
#[Groups(['task:read'])]
|
||||||
private Collection $documents;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tags = new ArrayCollection();
|
$this->tags = new ArrayCollection();
|
||||||
@@ -262,4 +269,16 @@ class Task
|
|||||||
{
|
{
|
||||||
return $this->documents;
|
return $this->documents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getClientTicket(): ?ClientTicket
|
||||||
|
{
|
||||||
|
return $this->clientTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||||
|
{
|
||||||
|
$this->clientTicket = $clientTicket;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(paginationEnabled: false),
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
|
||||||
processor: TaskDocumentProcessor::class,
|
processor: TaskDocumentProcessor::class,
|
||||||
deserialize: false,
|
deserialize: false,
|
||||||
),
|
),
|
||||||
@@ -40,37 +40,42 @@ class TaskDocument
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
#[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'])]
|
#[Groups(['task_document:read', 'task_document:write'])]
|
||||||
private ?Task $task = null;
|
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)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?string $originalName = null;
|
private ?string $originalName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?string $fileName = null;
|
private ?string $fileName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?string $mimeType = null;
|
private ?string $mimeType = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?int $size = null;
|
private ?int $size = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
#[Groups(['task_document:read', 'task:read'])]
|
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||||
private ?DateTimeImmutable $createdAt = null;
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
#[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;
|
private ?User $uploadedBy = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -161,4 +166,16 @@ class TaskDocument
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getClientTicket(): ?ClientTicket
|
||||||
|
{
|
||||||
|
return $this->clientTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||||
|
{
|
||||||
|
$this->clientTicket = $clientTicket;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
new Get(),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user