Compare commits
113 Commits
refactor/d
...
v0.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df0b15fe7 | ||
|
|
8040245e45 | ||
|
|
5d378c1f75 | ||
|
|
8544babf8c | ||
|
|
455121132d | ||
|
|
fd3097cc26 | ||
|
|
ff7cff1d39 | ||
|
|
ed58a402b0 | ||
|
|
2ac815d074 | ||
|
|
e0dfcbdbf8 | ||
|
|
5db6b1e2b0 | ||
|
|
6e29aeb30f | ||
|
|
cca548dfbc | ||
|
|
3d4b7fad12 | ||
|
|
5ffb4bbedc | ||
|
|
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 |
6
.env
6
.env
@@ -1,5 +1,5 @@
|
|||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
|
APP_SECRET="change_me_in_env_local"
|
||||||
APP_DEBUG=1
|
APP_DEBUG=1
|
||||||
|
|
||||||
DEFAULT_URI=http://localhost/
|
DEFAULT_URI=http://localhost/
|
||||||
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
|
|||||||
###> lexik/jwt-authentication-bundle ###
|
###> lexik/jwt-authentication-bundle ###
|
||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
|
JWT_PASSPHRASE=change_me_in_env_local
|
||||||
JWT_COOKIE_SECURE=0
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
ENCRYPTION_KEY=aaaaaaaaa
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
99
.env.example
Normal file
99
.env.example
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
###############################################################################
|
||||||
|
# Lesstime — Fichier d'environnement de reference
|
||||||
|
#
|
||||||
|
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
|
||||||
|
# Les valeurs par defaut dans .env suffisent pour le developpement ;
|
||||||
|
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
|
||||||
|
# etre definis dans .env.local.
|
||||||
|
#
|
||||||
|
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# App
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Environnement Symfony : dev, test, prod
|
||||||
|
APP_ENV=dev
|
||||||
|
|
||||||
|
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
|
||||||
|
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
|
||||||
|
APP_SECRET="change_me_in_env_local"
|
||||||
|
|
||||||
|
# Active/desactive le mode debug (1 = oui, 0 = non)
|
||||||
|
APP_DEBUG=1
|
||||||
|
|
||||||
|
# URI par defaut de l'application (utilise pour les liens absolus)
|
||||||
|
DEFAULT_URI=http://localhost/
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# CORS (nelmio/cors-bundle)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Origines autorisees pour les requetes cross-origin (regex)
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# JWT (lexik/jwt-authentication-bundle)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Chemin vers la cle privee RSA pour signer les tokens JWT
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
|
||||||
|
# Chemin vers la cle publique RSA pour verifier les tokens JWT
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
|
||||||
|
# Passphrase de la cle privee JWT — a generer pour chaque installation
|
||||||
|
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
JWT_PASSPHRASE=change_me_in_env_local
|
||||||
|
|
||||||
|
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
|
||||||
|
JWT_COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Duree de vie du token JWT en secondes (86400 = 24h)
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
|
||||||
|
# Duree de vie du cookie JWT en secondes (86400 = 24h)
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Base de donnees (Doctrine / PostgreSQL)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||||
|
# et injectees automatiquement par Docker Compose.
|
||||||
|
# DATABASE_URL est construite a partir de ces variables.
|
||||||
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Chiffrement
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
|
||||||
|
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Docker (docker/.env.docker)
|
||||||
|
#
|
||||||
|
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||||
|
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||||
|
# surcharger localement.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# DOCKER_APP_NAME=lesstime
|
||||||
|
# DOCKER_PHP_VERSION=8.4.6
|
||||||
|
# DOCKER_NODE_VERSION=24.12.0
|
||||||
|
# APP_USER=www-data
|
||||||
|
# POSTGRES_DB=lesstime
|
||||||
|
# POSTGRES_USER=root
|
||||||
|
# POSTGRES_PASSWORD=root
|
||||||
|
# POSTGRES_PORT=5435
|
||||||
|
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Frontend (frontend/.env)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
|
||||||
|
# NUXT_PUBLIC_API_BASE=/api
|
||||||
@@ -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
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@
|
|||||||
###> lexik/jwt-authentication-bundle ###
|
###> lexik/jwt-authentication-bundle ###
|
||||||
/config/jwt/*.pem
|
/config/jwt/*.pem
|
||||||
###< lexik/jwt-authentication-bundle ###
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
|
###> ide ###
|
||||||
|
.idea/
|
||||||
|
###< ide ###
|
||||||
|
|
||||||
|
###> docker local ###
|
||||||
|
docker/.env.docker.local
|
||||||
|
###< docker local ###
|
||||||
|
|||||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Ignored default folder with query files
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
8
.idea/Lesstime.iml
generated
8
.idea/Lesstime.iml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/db-forest-config.xml
generated
6
.idea/db-forest-config.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="db-tree-configuration">
|
|
||||||
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/material_theme_project_new.xml
generated
10
.idea/material_theme_project_new.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MaterialThemeProjectNewConfig">
|
|
||||||
<option name="metadata">
|
|
||||||
<MTProjectMetadataState>
|
|
||||||
<option name="userId" value="386cba74:19cc24e9181:-799b" />
|
|
||||||
</MTProjectMetadataState>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
20
.idea/php.xml
generated
20
.idea/php.xml
generated
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MessDetectorOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCSFixerOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
|
||||||
<option name="highlightLevel" value="WARNING" />
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
|
||||||
<component name="PhpStanOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PsalmOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
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, BookStackConfiguration, TaskBookStackLink)
|
||||||
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,12 +24,15 @@
|
|||||||
"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/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
|
"symfony/rate-limiter": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/twig-bundle": "8.0.*",
|
|
||||||
"symfony/validator": "8.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/yaml": "8.0.*"
|
"symfony/yaml": "8.0.*"
|
||||||
},
|
},
|
||||||
@@ -87,8 +91,6 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"phpunit/phpunit": "^13.0",
|
"phpunit/phpunit": "^13.0"
|
||||||
"symfony/browser-kit": "8.0.*",
|
|
||||||
"symfony/css-selector": "8.0.*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1993
composer.lock
generated
1993
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,13 @@ 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\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
FrameworkBundle::class => ['all' => true],
|
FrameworkBundle::class => ['all' => true],
|
||||||
TwigBundle::class => ['all' => true],
|
|
||||||
SecurityBundle::class => ['all' => true],
|
SecurityBundle::class => ['all' => true],
|
||||||
DoctrineBundle::class => ['all' => true],
|
DoctrineBundle::class => ['all' => true],
|
||||||
DoctrineMigrationsBundle::class => ['all' => true],
|
DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
@@ -22,4 +22,6 @@ 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],
|
||||||
|
MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Hello API Platform
|
title: Lesstime API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
|
|||||||
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.project_dir%/var/mcp-sessions'
|
||||||
|
ttl: 3600
|
||||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 7
|
||||||
|
channels: ["!event"]
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine", "!console"]
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!event"]
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!deprecation"]
|
||||||
|
buffer_size: 50
|
||||||
|
nested:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 30
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine"]
|
||||||
|
deprecation:
|
||||||
|
type: rotating_file
|
||||||
|
channels: [deprecation]
|
||||||
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
|
max_files: 7
|
||||||
@@ -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'
|
||||||
@@ -19,12 +22,21 @@ security:
|
|||||||
pattern: ^/login_check
|
pattern: ^/login_check
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
interval: '1 minute'
|
||||||
json_login:
|
json_login:
|
||||||
check_path: /login_check
|
check_path: /login_check
|
||||||
username_path: username
|
username_path: username
|
||||||
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 +62,8 @@ 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: 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:
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
twig:
|
|
||||||
file_name_pattern: '*.twig'
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
twig:
|
|
||||||
strict_variables: true
|
|
||||||
@@ -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.6'
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
DOCKER_APP_NAME=lesstime
|
|
||||||
DOCKER_PHP_VERSION=8.4.6
|
|
||||||
DOCKER_NODE_VERSION=24.12.0
|
|
||||||
APP_USER=www-data
|
|
||||||
POSTGRES_DB=lesstime
|
|
||||||
POSTGRES_USER=root
|
|
||||||
POSTGRES_PASSWORD=root
|
|
||||||
POSTGRES_PORT=5435
|
|
||||||
XDEBUG_CLIENT_HOST=192.168.0.124
|
|
||||||
@@ -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
|
||||||
|
|||||||
381
frontend/components/admin/AdminClientTicketTab.vue
Normal file
381
frontend/components/admin/AdminClientTicketTab.vue
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<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 id = extractIdFromIri(iri)
|
||||||
|
if (!id) return ''
|
||||||
|
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmitterName(iri: string | null): string {
|
||||||
|
if (!iri) return '-'
|
||||||
|
const id = extractIdFromIri(iri)
|
||||||
|
if (!id) return ''
|
||||||
|
return users.value.find(u => u.id === id)?.username ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||||
|
if (!iri) return undefined
|
||||||
|
const id = extractIdFromIri(iri)
|
||||||
|
if (!id) return undefined
|
||||||
|
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, ClientTicketWrite } 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 { id: number }).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 Partial<ClientTicketWrite>)
|
||||||
|
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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
|||||||
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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
@@ -267,7 +267,7 @@ async function handleArchive() {
|
|||||||
if (timerStore.activeEntry?.task) {
|
if (timerStore.activeEntry?.task) {
|
||||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||||
? timerStore.activeEntry.task
|
? timerStore.activeEntry.task
|
||||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||||
await timerStore.stop()
|
await timerStore.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{{ task.project.code }}-{{ task.number }}
|
{{ task.project.code }}-{{ task.number }}
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||||
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
|
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +568,7 @@ async function handleArchive() {
|
|||||||
if (timerStore.activeEntry?.task) {
|
if (timerStore.activeEntry?.task) {
|
||||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||||
? timerStore.activeEntry.task
|
? timerStore.activeEntry.task
|
||||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||||
await timerStore.stop()
|
await timerStore.stop()
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Full display: title + project + type dot + duration -->
|
<!-- Full display: title + project + type dot + duration -->
|
||||||
<template v-if="sizeLevel >= 3">
|
<template v-if="sizeLevel >= 3">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
||||||
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
||||||
@@ -39,13 +39,13 @@
|
|||||||
|
|
||||||
<!-- Medium: title + duration -->
|
<!-- Medium: title + duration -->
|
||||||
<template v-else-if="sizeLevel === 2">
|
<template v-else-if="sizeLevel === 2">
|
||||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
||||||
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Small: title only -->
|
<!-- Small: title only -->
|
||||||
<template v-else-if="sizeLevel === 1">
|
<template v-else-if="sizeLevel === 1">
|
||||||
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
|
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Tiny: just a colored bar, no text -->
|
<!-- Tiny: just a colored bar, no text -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
@@ -257,7 +257,7 @@ async function onSubmit() {
|
|||||||
if (isEditing.value && props.entry) {
|
if (isEditing.value && props.entry) {
|
||||||
await update(props.entry.id, payload)
|
await update(props.entry.id, payload)
|
||||||
} else {
|
} else {
|
||||||
await create(payload as any)
|
await create(payload as TimeEntryWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
||||||
Aucune activité pour cette période
|
{{ $t('timeEntries.noEntries') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||||
{{ entry.title || 'Sans titre' }}
|
{{ entry.title || $t('common.untitled') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-for="tag in entry.tags"
|
v-for="tag in entry.tags"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<!-- Delete action -->
|
<!-- Delete action -->
|
||||||
<button
|
<button
|
||||||
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||||
title="Supprimer"
|
:title="$t('common.delete')"
|
||||||
@click.stop="emit('deleteEntry', entry)"
|
@click.stop="emit('deleteEntry', entry)"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
<Icon name="mdi:delete-outline" size="18" />
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
|
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
|
||||||
<div class="text-[10px] text-neutral-500">
|
<div class="text-[10px] text-neutral-500">
|
||||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +141,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
startDate: Date
|
startDate: Date
|
||||||
@@ -459,7 +461,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
|
|||||||
dragState.value = {
|
dragState.value = {
|
||||||
entryId: entry.id,
|
entryId: entry.id,
|
||||||
entry,
|
entry,
|
||||||
title: entry.title || 'Sans titre',
|
title: entry.title || t('common.untitled'),
|
||||||
color: entry.project?.color ?? '#94a3b8',
|
color: entry.project?.color ?? '#94a3b8',
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -4,19 +4,18 @@
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
<div class="absolute inset-0 bg-black/30" @click="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">Supprimer le statut « {{ statusLabel }} »</h3>
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
||||||
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
|
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
||||||
Choisissez où les déplacer :
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="targetStatusId"
|
v-model="targetStatusId"
|
||||||
:options="targetOptions"
|
:options="targetOptions"
|
||||||
label="Déplacer vers"
|
:label="$t('taskStatuses.moveTo')"
|
||||||
empty-option-label="Backlog (sans statut)"
|
:empty-option-label="$t('taskStatuses.backlog')"
|
||||||
min-width="w-full"
|
min-width="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
>
|
||||||
Annuler
|
{{ $t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
@click="confirm"
|
@click="confirm"
|
||||||
>
|
>
|
||||||
Supprimer
|
{{ $t('common.delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
|
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -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,12 @@
|
|||||||
<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 { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -68,15 +107,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: t('common.noClient'), 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 +140,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,9 +188,11 @@ 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.plainPassword = form.password
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing.value && props.item) {
|
if (isEditing.value && props.item) {
|
||||||
|
|||||||
@@ -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,20 +170,23 @@ 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'
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||||
|
|
||||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||||
|
|
||||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
if (!isFormData) {
|
||||||
headers.set('Content-Type', 'application/merge-patch+json')
|
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
headers.set('Content-Type', 'application/merge-patch+json')
|
||||||
headers.set('Content-Type', 'application/json')
|
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return client<T>(url, { ...options, method, headers })
|
return client<T>(url, { ...options, method, headers })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
26
frontend/composables/useAvatarService.ts
Normal file
26
frontend/composables/useAvatarService.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 api.post<{ avatarUrl: string }>(
|
||||||
|
`/users/${userId}/avatar`,
|
||||||
|
formData as unknown as Record<string, unknown>,
|
||||||
|
{
|
||||||
|
toastSuccessKey: 'profile.avatarUpdated',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,43 +22,66 @@
|
|||||||
"clients": {
|
"clients": {
|
||||||
"created": "Client créé avec succès.",
|
"created": "Client créé avec succès.",
|
||||||
"updated": "Client mis à jour avec succès.",
|
"updated": "Client mis à jour avec succès.",
|
||||||
"deleted": "Client supprimé avec succès."
|
"deleted": "Client supprimé avec succès.",
|
||||||
|
"addClient": "Ajouter un client",
|
||||||
|
"editClient": "Modifier un client"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
"title": "Projets",
|
||||||
"created": "Projet créé avec succès.",
|
"created": "Projet créé avec succès.",
|
||||||
"updated": "Projet mis à jour avec succès.",
|
"updated": "Projet mis à jour avec succès.",
|
||||||
"deleted": "Projet supprimé avec succès.",
|
"deleted": "Projet supprimé avec succès.",
|
||||||
"archived": "Projet archivé avec succès.",
|
"archived": "Projet archivé avec succès.",
|
||||||
"unarchived": "Projet désarchivé avec succès.",
|
"unarchived": "Projet désarchivé avec succès.",
|
||||||
"showArchived": "Voir les projets archivés",
|
"showArchived": "Voir les projets archivés",
|
||||||
"hideArchived": "Masquer les projets archivés"
|
"hideArchived": "Masquer les projets archivés",
|
||||||
|
"noProjects": "Aucun projet trouvé.",
|
||||||
|
"noArchivedProjects": "Aucun projet archivé.",
|
||||||
|
"addProject": "Ajouter un projet",
|
||||||
|
"addProjectShort": "Projet",
|
||||||
|
"editProject": "Modifier un projet"
|
||||||
},
|
},
|
||||||
"taskStatuses": {
|
"taskStatuses": {
|
||||||
"created": "Statut créé avec succès.",
|
"created": "Statut créé avec succès.",
|
||||||
"updated": "Statut mis à jour avec succès.",
|
"updated": "Statut mis à jour avec succès.",
|
||||||
"deleted": "Statut supprimé avec succès."
|
"deleted": "Statut supprimé avec succès.",
|
||||||
|
"addStatus": "Ajouter un statut",
|
||||||
|
"editStatus": "Modifier un statut",
|
||||||
|
"deleteStatus": "Supprimer le statut « {label} »",
|
||||||
|
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
|
||||||
|
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
|
||||||
|
"moveTo": "Déplacer vers",
|
||||||
|
"backlog": "Backlog (sans statut)"
|
||||||
},
|
},
|
||||||
"taskEfforts": {
|
"taskEfforts": {
|
||||||
"created": "Effort créé avec succès.",
|
"created": "Effort créé avec succès.",
|
||||||
"updated": "Effort mis à jour avec succès.",
|
"updated": "Effort mis à jour avec succès.",
|
||||||
"deleted": "Effort supprimé avec succès."
|
"deleted": "Effort supprimé avec succès.",
|
||||||
|
"addEffort": "Ajouter un effort",
|
||||||
|
"editEffort": "Modifier un effort"
|
||||||
},
|
},
|
||||||
"taskPriorities": {
|
"taskPriorities": {
|
||||||
"created": "Priorité créée avec succès.",
|
"created": "Priorité créée avec succès.",
|
||||||
"updated": "Priorité mise à jour avec succès.",
|
"updated": "Priorité mise à jour avec succès.",
|
||||||
"deleted": "Priorité supprimée avec succès."
|
"deleted": "Priorité supprimée avec succès.",
|
||||||
|
"addPriority": "Ajouter une priorité",
|
||||||
|
"editPriority": "Modifier une priorité"
|
||||||
},
|
},
|
||||||
"taskTags": {
|
"taskTags": {
|
||||||
"created": "Tag créé avec succès.",
|
"created": "Tag créé avec succès.",
|
||||||
"updated": "Tag mis à jour avec succès.",
|
"updated": "Tag mis à jour avec succès.",
|
||||||
"deleted": "Tag supprimé avec succès."
|
"deleted": "Tag supprimé avec succès.",
|
||||||
|
"addTag": "Ajouter un tag",
|
||||||
|
"editTag": "Modifier un tag"
|
||||||
},
|
},
|
||||||
"taskGroups": {
|
"taskGroups": {
|
||||||
"created": "Groupe créé avec succès.",
|
"created": "Groupe créé avec succès.",
|
||||||
"updated": "Groupe mis à jour avec succès.",
|
"updated": "Groupe mis à jour avec succès.",
|
||||||
"deleted": "Groupe supprimé avec succès.",
|
"deleted": "Groupe supprimé avec succès.",
|
||||||
"archived": "Groupe archivé avec succès.",
|
"archived": "Groupe archivé avec succès.",
|
||||||
"unarchived": "Groupe désarchivé avec succès."
|
"unarchived": "Groupe désarchivé avec succès.",
|
||||||
|
"addGroup": "Ajouter un groupe",
|
||||||
|
"editGroup": "Modifier un groupe"
|
||||||
},
|
},
|
||||||
"taskDocuments": {
|
"taskDocuments": {
|
||||||
"title": "Documents",
|
"title": "Documents",
|
||||||
@@ -78,17 +101,24 @@
|
|||||||
"archived": "Ticket archivé avec succès.",
|
"archived": "Ticket archivé avec succès.",
|
||||||
"unarchived": "Ticket désarchivé avec succès.",
|
"unarchived": "Ticket désarchivé avec succès.",
|
||||||
"deleteConfirmTitle": "Supprimer le ticket",
|
"deleteConfirmTitle": "Supprimer le ticket",
|
||||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||||
|
"addTask": "Ajouter un ticket",
|
||||||
|
"editTask": "Modifier un ticket"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès."
|
"deleted": "Utilisateur supprimé avec succès.",
|
||||||
|
"addUser": "Ajouter un utilisateur",
|
||||||
|
"editUser": "Modifier un utilisateur"
|
||||||
},
|
},
|
||||||
"timeEntries": {
|
"timeEntries": {
|
||||||
"created": "Temps enregistré",
|
"created": "Temps enregistré",
|
||||||
"updated": "Temps modifié",
|
"updated": "Temps modifié",
|
||||||
"deleted": "Temps supprimé"
|
"deleted": "Temps supprimé",
|
||||||
|
"noEntries": "Aucune activité pour cette période",
|
||||||
|
"addEntry": "Ajouter une Activité",
|
||||||
|
"editEntry": "Modifier un temps"
|
||||||
},
|
},
|
||||||
"archive": {
|
"archive": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
@@ -112,7 +142,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 +197,20 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"loading": "Chargement..."
|
"save": "Enregistrer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"archived": "Archivé",
|
||||||
|
"noClient": "Aucun client",
|
||||||
|
"untitled": "Sans titre",
|
||||||
|
"dateFilter": "Date",
|
||||||
|
"today": "Aujourd'hui",
|
||||||
|
"thisWeek": "Cette semaine",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"day": "Jour",
|
||||||
|
"weekShort": "Sem."
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -213,6 +257,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>
|
||||||
@@ -148,6 +148,7 @@ import type { UserData } from '~/services/dto/user-data'
|
|||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import { useAppVersion } from '~/composables/useAppVersion'
|
import { useAppVersion } from '~/composables/useAppVersion'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -211,9 +212,9 @@ async function loadRefData() {
|
|||||||
if (refData.loaded) return
|
if (refData.loaded) return
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const [usersData, projectsData, typesData] = await Promise.all([
|
const [usersData, projectsData, typesData] = await Promise.all([
|
||||||
api.get<any>('/users'),
|
api.get<HydraCollection<UserData>>('/users'),
|
||||||
api.get<any>('/projects'),
|
api.get<HydraCollection<Project>>('/projects'),
|
||||||
api.get<any>('/task_tags'),
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||||
])
|
])
|
||||||
refData.users = extractHydraMembers(usersData)
|
refData.users = extractHydraMembers(usersData)
|
||||||
refData.projects = extractHydraMembers(projectsData)
|
refData.projects = extractHydraMembers(projectsData)
|
||||||
@@ -242,11 +243,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>
|
||||||
7
frontend/middleware/admin.ts
Normal file
7
frontend/middleware/admin.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ export default defineNuxtConfig({
|
|||||||
devServer: {
|
devServer: {
|
||||||
port: 3002,
|
port: 3002,
|
||||||
},
|
},
|
||||||
nitro: {
|
|
||||||
devProxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://nginx',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: [
|
components: [
|
||||||
{path: '~/components', pathPrefix: false},
|
{path: '~/components', pathPrefix: false},
|
||||||
],
|
],
|
||||||
@@ -62,5 +54,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>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: ['admin'] })
|
||||||
useHead({ title: 'Administration' })
|
useHead({ title: 'Administration' })
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -43,6 +45,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
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ const lineOptions = {
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx: any) => `${formatHours(ctx.raw)}`,
|
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -480,7 +480,7 @@ const lineOptions = {
|
|||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
grid: { color: '#f3f4f6' },
|
grid: { color: '#f3f4f6' },
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: (value: any) => `${value}h`,
|
callback: (value: number | string) => `${value}h`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
|
|||||||
@@ -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 h-[calc(100vh-260px)] 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)"
|
||||||
@@ -326,24 +340,26 @@ onMounted(() => {
|
|||||||
@drop.prevent="onDropStatus($event, status)"
|
@drop.prevent="onDropStatus($event, status)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||||
:style="{ backgroundColor: status.color }"
|
:style="{ backgroundColor: status.color }"
|
||||||
>
|
>
|
||||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3 p-3">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<TaskCard
|
<div class="flex flex-col gap-3">
|
||||||
v-for="task in tasksByStatus(status.id)"
|
<TaskCard
|
||||||
:key="task.id"
|
v-for="task in tasksByStatus(status.id)"
|
||||||
:task="task"
|
:key="task.id"
|
||||||
@click="openTaskEdit(task)"
|
:task="task"
|
||||||
/>
|
@click="openTaskEdit(task)"
|
||||||
<p
|
/>
|
||||||
v-if="tasksByStatus(status.id).length === 0"
|
<p
|
||||||
class="py-4 text-center text-xs text-neutral-400"
|
v-if="tasksByStatus(status.id).length === 0"
|
||||||
>
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
{{ $t('myTasks.noTasks') }}
|
>
|
||||||
</p>
|
{{ $t('myTasks.noTasks') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,12 +427,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 +462,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>
|
||||||
|
|||||||
84
frontend/pages/portal/index.vue
Normal file
84
frontend/pages/portal/index.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<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') {
|
||||||
|
const projectId = extractIdFromIri(ticket.project)
|
||||||
|
if (projectId) {
|
||||||
|
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 h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||||
|
<div
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.status"
|
||||||
|
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex shrink-0 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-0 flex-1 space-y-2 overflow-y-auto 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 h-[calc(100vh-200px)] 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)"
|
||||||
@@ -65,24 +74,26 @@
|
|||||||
@drop.prevent="onDropStatus($event, status)"
|
@drop.prevent="onDropStatus($event, status)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||||
:style="{ backgroundColor: status.color }"
|
:style="{ backgroundColor: status.color }"
|
||||||
>
|
>
|
||||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3 p-3">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<TaskCard
|
<div class="flex flex-col gap-3">
|
||||||
v-for="task in tasksByStatus(status.id)"
|
<TaskCard
|
||||||
:key="task.id"
|
v-for="task in tasksByStatus(status.id)"
|
||||||
:task="task"
|
:key="task.id"
|
||||||
@click="openTaskEdit(task)"
|
:task="task"
|
||||||
/>
|
@click="openTaskEdit(task)"
|
||||||
<p
|
/>
|
||||||
v-if="tasksByStatus(status.id).length === 0"
|
<p
|
||||||
class="py-4 text-center text-xs text-neutral-400"
|
v-if="tasksByStatus(status.id).length === 0"
|
||||||
>
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
Aucun ticket
|
>
|
||||||
</p>
|
Aucun ticket
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,6 +131,13 @@
|
|||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectDrawer
|
||||||
|
v-model="projectDrawerOpen"
|
||||||
|
:project="project"
|
||||||
|
:clients="clients"
|
||||||
|
@saved="onProjectSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,7 +150,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 +167,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 +184,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 +194,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 +241,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 +250,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 +260,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 +315,10 @@ async function onSaved() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onProjectSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<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 flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
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"
|
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="openCreate"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">+ Ajouter un projet</span>
|
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
|
||||||
<span class="sm:hidden">+ Projet</span>
|
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
v-if="project.archived"
|
v-if="project.archived"
|
||||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||||
>
|
>
|
||||||
Archivé
|
{{ $t('common.archived') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
v-if="projects.length === 0 && !isLoading"
|
v-if="projects.length === 0 && !isLoading"
|
||||||
class="col-span-full py-12 text-center text-neutral-400"
|
class="col-span-full py-12 text-center text-neutral-400"
|
||||||
>
|
>
|
||||||
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
|
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -124,6 +126,7 @@ import type { UserData } from '~/services/dto/user-data'
|
|||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import { useTimeEntryService } from '~/services/time-entries'
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
useHead({ title: 'Suivi des temps' })
|
useHead({ title: 'Suivi des temps' })
|
||||||
@@ -136,6 +139,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 +285,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()
|
||||||
@@ -319,9 +309,9 @@ async function loadReferenceData() {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const [usersData, projectsData, typesData] = await Promise.all([
|
const [usersData, projectsData, typesData] = await Promise.all([
|
||||||
api.get<any>('/users'),
|
api.get<HydraCollection<UserData>>('/users'),
|
||||||
api.get<any>('/projects'),
|
api.get<HydraCollection<Project>>('/projects'),
|
||||||
api.get<any>('/task_tags'),
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||||
])
|
])
|
||||||
|
|
||||||
users.value = extractHydraMembers(usersData)
|
users.value = extractHydraMembers(usersData)
|
||||||
@@ -330,6 +320,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 +341,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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user