Compare commits
281 Commits
feature/ta
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1219f3e73e | ||
|
|
ec35a1b2aa | ||
|
|
0113c08a60 | ||
|
|
c176511d97 | ||
|
|
64de971872 | ||
|
|
3dcc5c21a2 | ||
|
|
47768c0f02 | ||
|
|
b278b8a23a | ||
|
|
4074457499 | ||
|
|
b29b4d304d | ||
|
|
dd9db93751 | ||
|
|
3e2f3b3cf8 | ||
|
|
5bf768bc02 | ||
|
|
77c7ceb064 | ||
|
|
ac36eeba36 | ||
|
|
005b731a97 | ||
|
|
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 | ||
| c72f17eb93 | |||
| 4c19b68156 | |||
| 63e4af785e | |||
| f5e41bc377 | |||
| f978df6a4b | |||
| 98e832afa5 | |||
| cbfbb16c59 | |||
| 354d994766 | |||
| 06771c17e0 | |||
| 9908f34580 | |||
| 7bf632c1da | |||
| 66a75c6b6a | |||
| f53b2f3d1f | |||
| c9a3c7c5f8 | |||
| 5777e8386f | |||
| 06f2a9e1ea | |||
| b5fa9e7d06 | |||
| 73ecbbc95b | |||
| 5327155a80 | |||
| 9e638c32b8 | |||
| bc331982d5 | |||
| 1e311242a9 | |||
| 97c6ef6a52 | |||
| 245a8a932e | |||
| 28fbc73248 | |||
| df00b27a64 | |||
| ee38f99022 | |||
| 48ef434f8b | |||
| e53862d71f | |||
| 52063cb4fa | |||
| 06832c24e1 | |||
| 8fbafc1f8a | |||
| 585cc3368f | |||
| 043826075d | |||
| 8ec98a593a | |||
| 3dd2d39222 | |||
| cfaa6c42ec | |||
| a36cd92a7f | |||
| bfffbe7041 | |||
| c9993ef32d | |||
| efc3742fff | |||
| e047b98bed | |||
| 758c9f6fbd | |||
| 2c93e83e6b | |||
| 25b648a1b1 | |||
| 445f51b473 | |||
| f888a29e0a | |||
| b48ca10304 | |||
| 802659434f | |||
| 25aef9b2d5 | |||
| 0733ac16cd | |||
|
|
c0b16ef6dc | ||
|
|
c89f9c5596 | ||
|
|
94d7794c31 | ||
|
|
3c0baee661 | ||
|
|
c7a0dafae8 | ||
|
|
6eeacd2cb0 | ||
|
|
027e31e139 | ||
|
|
f8c94cb177 | ||
|
|
5b204a3464 | ||
|
|
92baf8ac0e | ||
|
|
2073339d4f | ||
|
|
e278286146 | ||
|
|
a6c5e54619 | ||
|
|
5135e28e3a | ||
|
|
3d0fad3735 | ||
|
|
dcbf5db308 | ||
|
|
7b1aa22c15 | ||
|
|
5577884c13 | ||
|
|
be2e7c60a3 | ||
|
|
136d0eaaa4 | ||
|
|
0b8e2bfc63 | ||
|
|
28e943b519 | ||
|
|
50690e6680 | ||
|
|
c82b6d1b32 | ||
|
|
6ae014fe8a | ||
|
|
3ec9424bb2 | ||
|
|
aa5f6cc7c1 | ||
|
|
14358fdddc | ||
|
|
3ffd18138b | ||
|
|
e5e722c019 | ||
|
|
bc9471e4ba | ||
|
|
cb5aa4584c | ||
|
|
1d0f9a28c3 | ||
|
|
d3ea09319c | ||
|
|
e85ea42d7c | ||
|
|
7540c99501 | ||
|
|
c60f531607 | ||
|
|
638bb2b686 | ||
|
|
7b8c754987 | ||
|
|
bf9faee5f4 | ||
|
|
7d1d81688e | ||
|
|
9a9e5093f5 | ||
|
|
7e7e373231 | ||
|
|
517511177c | ||
|
|
56275a9ebe | ||
|
|
dbae1f7536 | ||
|
|
d5d6452cf2 | ||
|
|
e6bbe66d42 | ||
|
|
0c4363d32b | ||
|
|
81d0433653 | ||
|
|
5057ef45c8 | ||
|
|
c097849dad | ||
|
|
7fe434fa07 | ||
|
|
4e391e2f57 | ||
|
|
84c85b3322 | ||
|
|
91ffb82e44 | ||
|
|
96a9f988c4 | ||
|
|
2c2ca0a8b6 | ||
|
|
e98d952871 | ||
|
|
8503111a4b | ||
|
|
6801dae0f2 | ||
|
|
73d0c7b4fa | ||
|
|
b76fd589cc | ||
|
|
20a5dca6d5 | ||
|
|
60b5aad0a4 | ||
|
|
3e6f4ecc7a | ||
|
|
dac493b76d | ||
|
|
37a6cb5558 | ||
|
|
cf84883530 | ||
|
|
ae8654d9ca | ||
|
|
9d5008a21d | ||
|
|
cbe3408b72 | ||
|
|
16c9b845a6 | ||
|
|
df29214509 | ||
|
|
5b8b4716df | ||
|
|
f06842729d | ||
|
|
1f74509475 | ||
|
|
0bf01cfb27 | ||
|
|
2ffdaafd08 | ||
|
|
33f2bcc393 | ||
|
|
f9d4de3e33 | ||
| c886506791 | |||
| 1efa0fa9ca | |||
| d28f385918 | |||
| ae3eeed7d9 | |||
| 7ee1be63b3 | |||
| c15a10b36f | |||
| 049275fd96 | |||
| a9ba2f3815 | |||
| 7484ce3e45 | |||
| d4c5660ba6 | |||
| 576922200c | |||
| 74116506db | |||
| cf021d6136 | |||
| 1e07eb1d64 | |||
| fa0adfde88 | |||
| e9ca888971 | |||
| 2299d66a9f | |||
| 66bb94fc98 | |||
| 50ae9ef549 | |||
| 95450e3b5f |
21
.env
21
.env
@@ -1,24 +1,23 @@
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=
|
||||
APP_SHARE_DIR=var/share
|
||||
###< symfony/framework-bundle ###
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
APP_DEBUG=1
|
||||
|
||||
###> symfony/routing ###
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
DEFAULT_URI=http://localhost/
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
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,12 +45,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
/config/jwt/*.pem
|
||||
###< 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -12,22 +12,28 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskType, TaskGroup)
|
||||
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/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, UserPasswordHasherProcessor)
|
||||
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/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, clients, projects, projects/[id], admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue (AppDrawer, ColorPicker, *Drawer, TaskCard, Admin*Tab, UserDrawer)
|
||||
frontend/composables/# Composables (useApi, etc.)
|
||||
frontend/stores/ # Stores Pinia
|
||||
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, etc.)
|
||||
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 (default, portal)
|
||||
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, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
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, client-tickets, notifications, task-documents)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -36,10 +42,14 @@ frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
|
||||
```bash
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter les containers
|
||||
make restart # Redémarrer les containers
|
||||
make install # Install complet (composer, migrations, fixtures, build Nuxt)
|
||||
make reset # Tout supprimer et réinstaller (supprime la BDD)
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||
make shell # Shell dans le container PHP
|
||||
make shell-root # Shell root dans le container PHP
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make migration-migrate # Lancer les migrations
|
||||
make fixtures # Charger les fixtures
|
||||
make db-reset # Reset BDD + migrations + fixtures
|
||||
@@ -65,18 +75,39 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
- TypeScript strict
|
||||
- Composable `useApi()` pour tous les appels API (gère cookies, erreurs, toasts, i18n)
|
||||
- Store Pinia pour l'auth (`useAuthStore`)
|
||||
- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui), `useTimerStore` (timer)
|
||||
- Middleware global `auth.global.ts` protège les routes
|
||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||
- 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
|
||||
|
||||
- `/_mcp` → Symfony (MCP HTTP transport)
|
||||
- `/api/*` → Symfony (via try_files + index.php)
|
||||
- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check`
|
||||
- `/` → SPA frontend (`frontend/dist/`)
|
||||
@@ -92,3 +123,6 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
## Fixtures
|
||||
|
||||
- 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
|
||||
|
||||
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",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^6.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
@@ -22,12 +23,16 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "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-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*"
|
||||
},
|
||||
@@ -86,8 +91,6 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/css-selector": "8.0.*"
|
||||
"phpunit/phpunit": "^13.0"
|
||||
}
|
||||
}
|
||||
|
||||
2167
composer.lock
generated
2167
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 Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
|
||||
return [
|
||||
FrameworkBundle::class => ['all' => true],
|
||||
TwigBundle::class => ['all' => true],
|
||||
SecurityBundle::class => ['all' => true],
|
||||
DoctrineBundle::class => ['all' => true],
|
||||
DoctrineMigrationsBundle::class => ['all' => true],
|
||||
@@ -22,4 +22,6 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
api_platform:
|
||||
title: Hello API Platform
|
||||
title: Lesstime API
|
||||
version: 1.0.0
|
||||
formats:
|
||||
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:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
@@ -19,12 +22,21 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
interval: '1 minute'
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
password_path: password
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
custom_authenticators:
|
||||
- App\Security\ApiTokenAuthenticator
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
@@ -50,6 +62,8 @@ security:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { 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 }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
@@ -1610,6 +1610,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
||||
* },
|
||||
* }
|
||||
* @psalm-type McpConfig = array{
|
||||
* app?: scalar|Param|null, // Default: "app"
|
||||
* version?: scalar|Param|null, // Default: "0.0.1"
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* icons?: list<array{ // Default: []
|
||||
* src?: scalar|Param|null,
|
||||
* mime_type?: scalar|Param|null, // Default: null
|
||||
* sizes?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* website_url?: scalar|Param|null, // Default: null
|
||||
* pagination_limit?: int|Param, // Default: 50
|
||||
* instructions?: scalar|Param|null, // Default: null
|
||||
* client_transports?: array{
|
||||
* stdio?: bool|Param, // Default: false
|
||||
* http?: bool|Param, // Default: false
|
||||
* },
|
||||
* discovery?: array{
|
||||
* scan_dirs?: list<scalar|Param|null>,
|
||||
* exclude_dirs?: list<scalar|Param|null>,
|
||||
* },
|
||||
* http?: array{
|
||||
* path?: scalar|Param|null, // Default: "/_mcp"
|
||||
* session?: array{
|
||||
* store?: "file"|"memory"|"cache"|Param, // Default: "file"
|
||||
* directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions"
|
||||
* cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions"
|
||||
* prefix?: scalar|Param|null, // Default: "mcp-"
|
||||
* ttl?: int|Param, // Default: 3600
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1622,6 +1653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1634,6 +1666,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1647,6 +1680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1660,6 +1694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
3
config/routes/mcp.yaml
Normal file
3
config/routes/mcp.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
@@ -7,6 +7,8 @@
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
@@ -24,3 +26,21 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventListener\TaskDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\State\TaskDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.0'
|
||||
app.version: '0.3.1'
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
@@ -56,3 +57,4 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads_data:
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,13 @@ server {
|
||||
root /var/www/html/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[Date]
|
||||
; Defines the default timezone used by the date functions
|
||||
; http://php.net/date.timezone
|
||||
date.timezone = Europe/Paris
|
||||
date.timezone = Europe/Paris
|
||||
|
||||
[Upload]
|
||||
upload_max_filesize = 50M
|
||||
post_max_size = 55M
|
||||
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 |
|
||||
816
docs/plans/2026-03-12-admin-clients-global-statuses.md
Normal file
816
docs/plans/2026-03-12-admin-clients-global-statuses.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Admin Clients + Global Statuses 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:** Move clients management into the admin page as a tab, and make task statuses global (shared across all projects) instead of per-project.
|
||||
|
||||
**Architecture:** Two independent changes: (1) Extract client CRUD from its dedicated page into an `AdminClientTab` component inside the admin page, remove the standalone `/clients` page and sidebar link. (2) Remove the `project` relationship from `TaskStatus` entity, update the frontend to use `getAll()` everywhere instead of `getByProject()`, remove per-project status management pages/links, and update `AdminStatusTab` + `TaskStatusDrawer` to work without `projectId`.
|
||||
|
||||
**Tech Stack:** PHP 8.4 / Symfony 8 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / TypeScript (frontend)
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Move Clients into Admin
|
||||
|
||||
### Task 1: Create AdminClientTab component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/admin/AdminClientTab.vue`
|
||||
|
||||
- [ ] **Step 1: Create AdminClientTab.vue**
|
||||
|
||||
Extract the logic from `frontend/pages/clients.vue` into a new admin tab component, following the same pattern as `AdminPriorityTab.vue` (h2 title instead of h1, no `useHead`).
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="clients"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun client trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-email="{ item }">
|
||||
{{ item.email ?? '-' }}
|
||||
</template>
|
||||
<template #cell-address="{ item }">
|
||||
{{ formatAddress(item) }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ item.phone ?? '-' }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ClientDrawer
|
||||
v-model="drawerOpen"
|
||||
:client="selectedClient"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'name', label: 'Nom', primary: true },
|
||||
{ key: 'email', label: 'Email', class: 'text-primary-500' },
|
||||
{ key: 'address', label: 'Adresse', class: 'text-neutral-700' },
|
||||
{ key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useClientService()
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
|
||||
async function loadClients() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
clients.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedClient.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(client: Client) {
|
||||
selectedClient.value = client
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function formatAddress(client: Client): string {
|
||||
return [client.street, client.postalCode, client.city]
|
||||
.filter(Boolean)
|
||||
.join(', ') || '-'
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/admin/AdminClientTab.vue
|
||||
git commit -m "feat(admin) : add AdminClientTab component"
|
||||
```
|
||||
|
||||
### Task 2: Add Clients tab to admin page and remove standalone page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/admin.vue`
|
||||
- Delete: `frontend/pages/clients.vue`
|
||||
|
||||
- [ ] **Step 3: Update admin.vue to include Clients tab**
|
||||
|
||||
Add `AdminClientTab` to the admin page. Add the tab entry to the `tabs` array and the corresponding `v-if` block:
|
||||
|
||||
In `frontend/pages/admin.vue`, update the `tabs` array:
|
||||
|
||||
```typescript
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'types', label: 'Types' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
] as const
|
||||
```
|
||||
|
||||
Change the default active tab:
|
||||
|
||||
```typescript
|
||||
const activeTab = ref<TabKey>('clients')
|
||||
```
|
||||
|
||||
Add the component in the template `<div class="mt-6">` block:
|
||||
|
||||
```html
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete standalone clients page**
|
||||
|
||||
Delete `frontend/pages/clients.vue`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/admin.vue
|
||||
git rm frontend/pages/clients.vue
|
||||
git commit -m "feat(admin) : move clients into admin page, remove standalone page"
|
||||
```
|
||||
|
||||
### Task 3: Remove clients sidebar link
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/layouts/default.vue`
|
||||
|
||||
- [ ] **Step 6: Remove the Clients SidebarLink from default.vue**
|
||||
|
||||
Remove the following block from `frontend/layouts/default.vue` (lines 60-65):
|
||||
|
||||
```html
|
||||
<SidebarLink
|
||||
to="/clients"
|
||||
icon="mdi:account-group-outline"
|
||||
label="Clients"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "refactor(frontend) : remove clients sidebar link"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Make Task Statuses Global
|
||||
|
||||
### Task 4: Remove project relationship from TaskStatus entity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/TaskStatus.php`
|
||||
|
||||
- [ ] **Step 8: Update TaskStatus entity to remove project relationship**
|
||||
|
||||
In `src/Entity/TaskStatus.php`:
|
||||
|
||||
1. Remove the `SearchFilter` import and `#[ApiFilter]` attribute
|
||||
2. Remove the `$project` property, its `#[ORM]` annotations, and the `getProject()`/`setProject()` methods
|
||||
|
||||
The entity should become:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_status:read']],
|
||||
denormalizationContext: ['groups' => ['task_status:write']],
|
||||
order: ['position' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
|
||||
class TaskStatus
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?string $color = '#222783';
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): ?string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function setColor(string $color): static
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): ?int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/TaskStatus.php
|
||||
git commit -m "refactor(backend) : remove project relationship from TaskStatus entity"
|
||||
```
|
||||
|
||||
### Task 5: Generate and run Doctrine migration
|
||||
|
||||
- [ ] **Step 10: Generate the migration**
|
||||
|
||||
```bash
|
||||
make shell
|
||||
# Inside container:
|
||||
php bin/console doctrine:migrations:diff
|
||||
exit
|
||||
```
|
||||
|
||||
This should generate a migration that:
|
||||
- Drops the `project_id` foreign key from `task_status` table
|
||||
- Drops the `project_id` column from `task_status` table
|
||||
|
||||
- [ ] **Step 11: Review the migration**
|
||||
|
||||
Read the generated migration file in `migrations/` to verify it only drops the FK and column.
|
||||
|
||||
- [ ] **Step 12: Reset database (since structure changed significantly)**
|
||||
|
||||
```bash
|
||||
make db-reset
|
||||
```
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/
|
||||
git commit -m "feat(backend) : add migration to remove project_id from task_status"
|
||||
```
|
||||
|
||||
### Task 6: Update fixtures for global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/DataFixtures/AppFixtures.php`
|
||||
|
||||
- [ ] **Step 14: Update fixtures to create global statuses instead of per-project**
|
||||
|
||||
In `src/DataFixtures/AppFixtures.php`, replace the entire per-project status block (lines 95-124, from `// Task Statuses (per project)` through `$statusDone = $sirhStatuses['Terminé'];`) with global creation:
|
||||
|
||||
```php
|
||||
// Task Statuses (global)
|
||||
$defaultStatuses = [
|
||||
['A faire', '#222783', 0],
|
||||
['En cours', '#4A90D9', 1],
|
||||
['Bloqué', '#C62828', 2],
|
||||
['En attente de validation', '#FF8F00', 3],
|
||||
['Terminé', '#26A69A', 4],
|
||||
];
|
||||
|
||||
$statusObjects = [];
|
||||
foreach ($defaultStatuses as [$label, $color, $position]) {
|
||||
$status = new TaskStatus();
|
||||
$status->setLabel($label);
|
||||
$status->setColor($color);
|
||||
$status->setPosition($position);
|
||||
$manager->persist($status);
|
||||
$statusObjects[$label] = $status;
|
||||
}
|
||||
|
||||
$statusTodo = $statusObjects['A faire'];
|
||||
$statusInProgress = $statusObjects['En cours'];
|
||||
$statusBlocked = $statusObjects['Bloqué'];
|
||||
$statusReview = $statusObjects['En attente de validation'];
|
||||
$statusDone = $statusObjects['Terminé'];
|
||||
```
|
||||
|
||||
This replaces the loop that created statuses per-project AND the `$statusesByProject` / `$sirhStatuses` extraction lines (95-124). The task variable references (`$statusTodo`, etc.) remain identical so downstream task creation is unchanged.
|
||||
|
||||
- [ ] **Step 15: Reload fixtures to verify**
|
||||
|
||||
```bash
|
||||
make db-reset
|
||||
```
|
||||
|
||||
- [ ] **Step 16: Commit**
|
||||
|
||||
```bash
|
||||
git add src/DataFixtures/AppFixtures.php
|
||||
git commit -m "fix(fixtures) : create global statuses instead of per-project"
|
||||
```
|
||||
|
||||
### Task 7: Update frontend DTO and service for global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/task-status.ts`
|
||||
- Modify: `frontend/services/task-statuses.ts`
|
||||
|
||||
- [ ] **Step 17: Update TaskStatus DTO to remove project field**
|
||||
|
||||
In `frontend/services/dto/task-status.ts`, remove the `project` import and field from both types:
|
||||
|
||||
```typescript
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 18: Remove getByProject from task-statuses service**
|
||||
|
||||
In `frontend/services/task-statuses.ts`, remove the `getByProject` function and its return:
|
||||
|
||||
```typescript
|
||||
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskStatusService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskStatus[]> {
|
||||
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
|
||||
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
|
||||
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_statuses/${id}`, {}, {
|
||||
toastSuccessKey: 'taskStatuses.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 19: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/dto/task-status.ts frontend/services/task-statuses.ts
|
||||
git commit -m "refactor(frontend) : remove project from TaskStatus DTO and service"
|
||||
```
|
||||
|
||||
### Task 8: Update TaskStatusDrawer to remove projectId
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskStatusDrawer.vue`
|
||||
|
||||
- [ ] **Step 20: Remove projectId prop from TaskStatusDrawer**
|
||||
|
||||
In `frontend/components/task/TaskStatusDrawer.vue`:
|
||||
|
||||
1. Remove `projectId` from props:
|
||||
|
||||
```typescript
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskStatus | null
|
||||
}>()
|
||||
```
|
||||
|
||||
2. Remove the `project` field from the payload in `handleSubmit`:
|
||||
|
||||
```typescript
|
||||
const payload: TaskStatusWrite = {
|
||||
label: form.label.trim(),
|
||||
position: Number(form.position),
|
||||
color: form.color,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 21: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskStatusDrawer.vue
|
||||
git commit -m "refactor(frontend) : remove projectId from TaskStatusDrawer"
|
||||
```
|
||||
|
||||
### Task 9: Add getAll to task service and update AdminStatusTab
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/tasks.ts`
|
||||
- Modify: `frontend/components/admin/AdminStatusTab.vue`
|
||||
|
||||
- [ ] **Step 22: Add getAll() method to task service**
|
||||
|
||||
`frontend/services/tasks.ts` currently only has `getByProject()`. Add a `getAll` function (needed by AdminStatusTab to check all tasks across projects when deleting a status).
|
||||
|
||||
Add this function inside `useTaskService()`, before `getByProject`:
|
||||
|
||||
```typescript
|
||||
async function getAll(): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
```
|
||||
|
||||
Update the return statement to include it:
|
||||
|
||||
```typescript
|
||||
return { getAll, getByProject, create, update, remove }
|
||||
```
|
||||
|
||||
- [ ] **Step 23: Update AdminStatusTab to handle task reassignment on delete**
|
||||
|
||||
The existing `AdminStatusTab` does a simple `remove(id)` which would leave tasks orphaned. Port the reassignment logic from `ProjectStatusTab` (which is being deleted). Since statuses are now global, we need to load ALL tasks (not per-project) to check for affected tasks.
|
||||
|
||||
Replace the full content of `frontend/components/admin/AdminStatusTab.vue` with:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un statut
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun statut trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="requestDelete"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteStatusModal
|
||||
v-model="confirmModalOpen"
|
||||
:status-label="statusToDelete?.label ?? ''"
|
||||
:task-count="affectedTaskCount"
|
||||
:available-statuses="reassignTargets"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
||||
]
|
||||
|
||||
const statusService = useTaskStatusService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | null>(null)
|
||||
const confirmModalOpen = ref(false)
|
||||
const statusToDelete = ref<TaskStatus | null>(null)
|
||||
|
||||
const affectedTaskCount = computed(() => {
|
||||
if (!statusToDelete.value) return 0
|
||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
||||
})
|
||||
|
||||
const reassignTargets = computed(() => {
|
||||
if (!statusToDelete.value) return items.value
|
||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
||||
})
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [statuses, allTasks] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
taskService.getAll(),
|
||||
])
|
||||
items.value = statuses
|
||||
tasks.value = allTasks
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function requestDelete(item: TaskStatus) {
|
||||
statusToDelete.value = item
|
||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
||||
if (count === 0) {
|
||||
await statusService.remove(item.id)
|
||||
await loadItems()
|
||||
} else {
|
||||
confirmModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmDelete(targetStatusId: number | null) {
|
||||
if (!statusToDelete.value) return
|
||||
|
||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
||||
|
||||
await Promise.all(
|
||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
||||
)
|
||||
|
||||
await statusService.remove(statusToDelete.value.id)
|
||||
confirmModalOpen.value = false
|
||||
statusToDelete.value = null
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 24: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/tasks.ts frontend/components/admin/AdminStatusTab.vue
|
||||
git commit -m "feat(admin) : add task reassignment logic to AdminStatusTab"
|
||||
```
|
||||
|
||||
### Task 10: Add Statuts tab to admin page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/admin.vue`
|
||||
|
||||
- [ ] **Step 24: Add Statuts tab to admin.vue**
|
||||
|
||||
In `frontend/pages/admin.vue`, update the `tabs` array to include statuses:
|
||||
|
||||
```typescript
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'statuses', label: 'Statuts' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'types', label: 'Types' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
] as const
|
||||
```
|
||||
|
||||
Add the component in the template:
|
||||
|
||||
```html
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
```
|
||||
|
||||
- [ ] **Step 25: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/admin.vue
|
||||
git commit -m "feat(admin) : add statuts tab to admin page"
|
||||
```
|
||||
|
||||
### Task 11: Update kanban page to use global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/projects/[id]/index.vue`
|
||||
|
||||
- [ ] **Step 26: Change kanban to load global statuses**
|
||||
|
||||
In `frontend/pages/projects/[id]/index.vue`, in the `loadData` function, change:
|
||||
|
||||
```typescript
|
||||
statusService.getByProject(projectId.value),
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
statusService.getAll(),
|
||||
```
|
||||
|
||||
- [ ] **Step 27: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/projects/[id]/index.vue
|
||||
git commit -m "refactor(frontend) : load global statuses in kanban page"
|
||||
```
|
||||
|
||||
### Task 12: Remove per-project status pages, sidebar link, and orphaned components
|
||||
|
||||
**Files:**
|
||||
- Delete: `frontend/pages/projects/[id]/statuses.vue`
|
||||
- Delete: `frontend/components/project/ProjectStatusTab.vue`
|
||||
- Modify: `frontend/layouts/default.vue`
|
||||
|
||||
- [ ] **Step 28: Delete per-project statuses page and ProjectStatusTab**
|
||||
|
||||
```bash
|
||||
git rm frontend/pages/projects/[id]/statuses.vue
|
||||
git rm frontend/components/project/ProjectStatusTab.vue
|
||||
```
|
||||
|
||||
- [ ] **Step 29: Remove statuses SidebarLink from default.vue**
|
||||
|
||||
In `frontend/layouts/default.vue`, remove the statuses sidebar link block (inside the `v-if="currentProjectId"` template, lines 52-58):
|
||||
|
||||
```html
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/statuses`"
|
||||
icon="mdi:list-status"
|
||||
label="Statuts"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
sub
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 30: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "refactor(frontend) : remove per-project statuses page and sidebar link"
|
||||
```
|
||||
|
||||
### Task 13: Verify and clean up
|
||||
|
||||
- [ ] **Step 31: Check for remaining references to getByProject in task-statuses**
|
||||
|
||||
Search for any remaining `getByProject` calls on the status service and `projectId` references in status-related components:
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime
|
||||
grep -rn "getByProject\|projectId" frontend/ --include="*.vue" --include="*.ts" | grep -i status
|
||||
grep -rn "ConfirmDeleteStatusModal\|ProjectStatusTab" frontend/ --include="*.vue" --include="*.ts"
|
||||
```
|
||||
|
||||
Fix any remaining references found.
|
||||
|
||||
- [ ] **Step 32: Run the dev server and verify**
|
||||
|
||||
```bash
|
||||
make db-reset && make dev-nuxt
|
||||
```
|
||||
|
||||
Verify:
|
||||
1. Admin page shows Clients tab with full CRUD (create, edit, delete)
|
||||
2. Admin page shows Statuts tab with global statuses CRUD
|
||||
3. Sidebar no longer shows "Clients" or per-project "Statuts" links
|
||||
4. Kanban board displays all global statuses as columns
|
||||
5. No errors in browser console
|
||||
|
||||
- [ ] **Step 33: Final commit if any cleanup was needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore : clean up remaining references after global statuses refactor"
|
||||
```
|
||||
1829
docs/superpowers/plans/2026-03-10-time-tracking.md
Normal file
1829
docs/superpowers/plans/2026-03-10-time-tracking.md
Normal file
File diff suppressed because it is too large
Load Diff
1180
docs/superpowers/plans/2026-03-12-task-archiving.md
Normal file
1180
docs/superpowers/plans/2026-03-12-task-archiving.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
# Time Entry Multi-Type Selection 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:** Allow selecting multiple task types in the TimeEntryDrawer, matching the existing multi-select pattern used in TaskDrawer.
|
||||
|
||||
**Architecture:** Replace the single-select `MalioSelect` dropdown for types with the checkbox-based colored badge multi-select already used in TaskDrawer. The backend (ManyToMany relation) and DTO (`types: string[]`) already support multiple types — only the frontend form state and template need updating.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Multi-Type Select in TimeEntryDrawer
|
||||
|
||||
### Task 1: Update TimeEntryDrawer to support multiple type selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/time-tracking/TimeEntryDrawer.vue`
|
||||
|
||||
- [ ] **Step 1: Change form state from single typeId to typeIds array**
|
||||
|
||||
In the `form` reactive object (line 133-142), replace:
|
||||
|
||||
```typescript
|
||||
typeId: null as number | null,
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
typeIds: [] as number[],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add toggleType function**
|
||||
|
||||
Add this function after the `durationLabel` computed (after line 165):
|
||||
|
||||
```typescript
|
||||
function toggleType(id: number) {
|
||||
const idx = form.typeIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.typeIds.splice(idx, 1)
|
||||
} else {
|
||||
form.typeIds.push(id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the typeOptions computed**
|
||||
|
||||
Delete the `typeOptions` computed (lines 152-154):
|
||||
|
||||
```typescript
|
||||
const typeOptions = computed(() =>
|
||||
props.types.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
```
|
||||
|
||||
This is no longer needed since we won't use `MalioSelect`.
|
||||
|
||||
- [ ] **Step 4: Replace MalioSelect template with multi-select badges**
|
||||
|
||||
Replace the `MalioSelect` for type (lines 75-81):
|
||||
|
||||
```vue
|
||||
<MalioSelect
|
||||
v-model="form.typeId"
|
||||
:options="typeOptions"
|
||||
label="Type"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
/>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```vue
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Types</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="type in types"
|
||||
:key="type.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.typeIds.includes(type.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="type.id"
|
||||
:checked="form.typeIds.includes(type.id)"
|
||||
@change="toggleType(type.id)"
|
||||
/>
|
||||
{{ type.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update populateForm to use typeIds**
|
||||
|
||||
In the `populateForm` function, replace (line 194):
|
||||
|
||||
```typescript
|
||||
form.typeId = entry.types?.[0]?.id ?? null
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
form.typeIds = entry.types?.map(t => t.id) ?? []
|
||||
```
|
||||
|
||||
And in the else branch (line 203), replace:
|
||||
|
||||
```typescript
|
||||
form.typeId = null
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
form.typeIds = []
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update onSubmit payload to use typeIds**
|
||||
|
||||
In the `onSubmit` function, replace (line 233):
|
||||
|
||||
```typescript
|
||||
types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
types: form.typeIds.map(id => `/api/task_types/${id}`),
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify in browser**
|
||||
|
||||
Run: `make dev-nuxt`
|
||||
|
||||
1. Open time tracking page
|
||||
2. Open an existing time entry → verify existing types are pre-selected as colored badges
|
||||
3. Toggle types on/off → verify visual feedback (colored background when selected)
|
||||
4. Save → verify types are persisted correctly
|
||||
5. Create a new time entry with multiple types → verify they save correctly
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/time-tracking/TimeEntryDrawer.vue
|
||||
git commit -m "feat(frontend) : allow multiple type selection in time entry drawer"
|
||||
```
|
||||
2248
docs/superpowers/plans/2026-03-13-gitea-integration.md
Normal file
2248
docs/superpowers/plans/2026-03-13-gitea-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
584
docs/superpowers/plans/2026-03-13-my-tasks-page.md
Normal file
584
docs/superpowers/plans/2026-03-13-my-tasks-page.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# My Tasks Page 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 "/my-tasks" page that displays all non-archived tasks across projects with Kanban and List views, filtered by current user by default.
|
||||
|
||||
**Architecture:** Backend: add SearchFilter annotations on Task entity for server-side filtering. Frontend: new page with filter bar + two view modes (Kanban/List), reusing existing TaskCard and TaskModal components.
|
||||
|
||||
**Tech Stack:** PHP 8.4 / API Platform 4 (SearchFilter), Nuxt 4 / Vue 3, Tailwind CSS, MalioSelect, Pinia
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-13-my-tasks-page-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend — API Filters
|
||||
|
||||
### Task 1: Add SearchFilter annotations on Task entity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/Task.php:35` (ApiFilter line)
|
||||
|
||||
- [ ] **Step 1: Add new SearchFilter properties**
|
||||
|
||||
In `src/Entity/Task.php`, replace the existing `#[ApiFilter(SearchFilter::class, ...)]` line (line 35) with an expanded version that includes `assignee`, `priority`, `effort`, `tags`, and `status`:
|
||||
|
||||
```php
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Disable pagination on GetCollection**
|
||||
|
||||
In `src/Entity/Task.php`, modify the `GetCollection` operation (line 25) to disable pagination:
|
||||
|
||||
```php
|
||||
new GetCollection(paginationEnabled: false),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify filters work**
|
||||
|
||||
Run in the container:
|
||||
```bash
|
||||
docker exec -t php-lesstime-fpm php bin/console debug:router | grep tasks
|
||||
```
|
||||
Then test the API call:
|
||||
```bash
|
||||
curl -s 'http://localhost:8082/api/tasks?archived=false&assignee=/api/users/1' -H 'Cookie: BEARER=...' | head -c 500
|
||||
```
|
||||
Expected: JSON response with filtered tasks.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/Task.php
|
||||
git commit -m "feat(backend) : add SearchFilter for assignee, priority, effort, tags, status on Task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend — Service, i18n, Sidebar
|
||||
|
||||
### Task 2: Add `getFiltered` method to task service
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/tasks.ts`
|
||||
|
||||
- [ ] **Step 1: Add the `getFiltered` method**
|
||||
|
||||
Add after the `getByProjectArchived` method (after line 27) in `frontend/services/tasks.ts`:
|
||||
|
||||
```typescript
|
||||
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export the new method**
|
||||
|
||||
Update the return statement (line 47) to include `getFiltered`:
|
||||
|
||||
```typescript
|
||||
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/tasks.ts
|
||||
git commit -m "feat(frontend) : add getFiltered method to task service"
|
||||
```
|
||||
|
||||
### Task 3: Add i18n translations
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json`
|
||||
|
||||
- [ ] **Step 1: Add myTasks and sidebar keys**
|
||||
|
||||
Add these entries to `frontend/i18n/locales/fr.json` (before the closing `}`):
|
||||
|
||||
```json
|
||||
"myTasks": {
|
||||
"title": "Mes tâches",
|
||||
"viewKanban": "Vue Kanban",
|
||||
"viewList": "Vue Liste",
|
||||
"allProjects": "Tous les projets",
|
||||
"allGroups": "Tous les groupes",
|
||||
"allTypes": "Tous les types",
|
||||
"allPriorities": "Toutes les priorités",
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(frontend) : add i18n translations for my-tasks page"
|
||||
```
|
||||
|
||||
### Task 4: Add sidebar navigation link
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/layouts/default.vue:23-35` (nav section)
|
||||
|
||||
- [ ] **Step 1: Add SidebarLink for "Mes tâches"**
|
||||
|
||||
In `frontend/layouts/default.vue`, add a new `SidebarLink` between the "Tableau de bord" link (line 29) and the "Projets" link (line 30):
|
||||
|
||||
```vue
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "feat(frontend) : add Mes tâches link to sidebar navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Frontend — My Tasks Page (Kanban + List views)
|
||||
|
||||
### Task 5: Create the my-tasks page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/pages/my-tasks.vue`
|
||||
|
||||
- [ ] **Step 1: Create the page file with imports and data loading**
|
||||
|
||||
Create `frontend/pages/my-tasks.vue` with the full page implementation. The page structure:
|
||||
|
||||
**Script section** — data loading pattern (same as `projects/[id]/index.vue`):
|
||||
|
||||
```typescript
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
// Filter options
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let g = groups.value.filter(g => !g.archived)
|
||||
if (selectedProjectId.value) {
|
||||
g = g.filter(g => g.project?.id === selectedProjectId.value)
|
||||
}
|
||||
return g.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
)
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return tasks.value.filter(t => t.status?.id === statusId)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
// Data loading
|
||||
async function loadReferenceData() {
|
||||
const [s, e, pr, tg, g, u, p] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll(),
|
||||
projectService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = tg
|
||||
groups.value = g
|
||||
users.value = u
|
||||
projects.value = p
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
// Reset group when project changes (no extra loadTasks — the above watcher handles it)
|
||||
watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Modal
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Template section:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 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="20" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<!-- Backlog column (tasks without status) -->
|
||||
<div
|
||||
v-if="backlogTasks.length > 0"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
|
||||
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status columns -->
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="selectedTask?.project?.id ?? 0"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Note: add the `timerStore` and `isTimerOnTask` helper in the script section:
|
||||
|
||||
```typescript
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = task['@id'] ?? task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the page loads**
|
||||
|
||||
Run: `make dev-nuxt`
|
||||
|
||||
Navigate to `http://localhost:3002/my-tasks`.
|
||||
Expected: page loads with filters and shows tasks assigned to current user in Kanban view.
|
||||
|
||||
- [ ] **Step 3: Test view toggle**
|
||||
|
||||
Click the list icon. Expected: tasks display in list format with title, badges, project code.
|
||||
Click the kanban icon. Expected: tasks display in columns by status.
|
||||
|
||||
- [ ] **Step 4: Test filters**
|
||||
|
||||
Change the assignee filter to "Tous". Expected: all tasks from all users appear.
|
||||
Select a specific project. Expected: only tasks from that project appear.
|
||||
Reset all filters. Expected: all non-archived tasks appear.
|
||||
|
||||
- [ ] **Step 5: Test TaskModal integration**
|
||||
|
||||
Click on a task card/row. Expected: TaskModal opens with task details pre-filled.
|
||||
Edit and save. Expected: modal closes, tasks reload with updated data.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/my-tasks.vue
|
||||
git commit -m "feat(frontend) : add my-tasks page with Kanban and List views"
|
||||
```
|
||||
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
File diff suppressed because it is too large
Load Diff
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
1302
docs/superpowers/plans/2026-03-15-task-documents.md
Normal file
1302
docs/superpowers/plans/2026-03-15-task-documents.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**
|
||||
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Time Tracking (Toggl-style Timer)
|
||||
|
||||
## Résumé
|
||||
|
||||
Système de suivi de temps type Toggl intégré à Lesstime. Permet de démarrer des timers depuis les tickets (TaskCard) ou à vide depuis la sidebar, visualiser les temps sur un calendrier semaine/jour, et gérer les entrées de temps (drag, resize, copier-coller).
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Entité `TimeEntry`
|
||||
|
||||
| Champ | Type | Contraintes |
|
||||
|-------|------|-------------|
|
||||
| `id` | integer | PK, auto-increment |
|
||||
| `title` | string(255) | nullable |
|
||||
| `description` | text | nullable |
|
||||
| `startedAt` | datetimetz_immutable | requis (stocké en UTC) |
|
||||
| `stoppedAt` | datetimetz_immutable | nullable (null = timer actif, stocké en UTC) |
|
||||
| `user` | ManyToOne → User | requis, CASCADE on delete |
|
||||
| `project` | ManyToOne → Project | nullable, SET NULL on delete |
|
||||
| `task` | ManyToOne → Task | nullable, SET NULL on delete |
|
||||
| `types` | ManyToMany → TaskType | join table `time_entry_task_type` |
|
||||
|
||||
### Règles métier
|
||||
|
||||
- Un seul timer actif (`stoppedAt = null`) par user à la fois
|
||||
- `stoppedAt` > `startedAt` si renseigné
|
||||
- Les entrées de temps peuvent se chevaucher
|
||||
- Démarrage depuis un ticket : copie `title`, `project`, `task`, `types` depuis la Task. Le `user` est toujours le user connecté (pas l'assignee du ticket)
|
||||
- Démarrage à vide : seuls `startedAt` et `user` (connecté) sont renseignés, le reste peut être complété après
|
||||
- Unicité timer actif : index partiel unique sur `(user_id) WHERE stopped_at IS NULL`
|
||||
- Entrées traversant minuit : tronquées visuellement à la fin du jour, la suite s'affiche dans la colonne du jour suivant
|
||||
- Toutes les dates sont stockées et échangées en UTC. Le frontend convertit en heure locale pour l'affichage
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Préfixe `/api`.
|
||||
|
||||
### Sécurité / Autorisations
|
||||
|
||||
- Tout user authentifié peut lire les entrées de tous les users (filtrage par user côté frontend)
|
||||
- Un user peut créer/modifier/supprimer ses propres entrées
|
||||
- Un ROLE_ADMIN peut créer/modifier/supprimer les entrées de n'importe qui
|
||||
- Assigner un temps à un autre user (`user` ≠ soi-même) requiert ROLE_ADMIN
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/time_entries` | Liste avec filtres : `user`, `project`, `startedAt[after]`, `startedAt[before]`, `types` |
|
||||
| `POST` | `/api/time_entries` | Créer une entrée ou démarrer un timer |
|
||||
| `PATCH` | `/api/time_entries/{id}` | Modifier (stopper, compléter, redimensionner, déplacer) |
|
||||
| `DELETE` | `/api/time_entries/{id}` | Supprimer |
|
||||
| `GET` | `/api/time_entries/active` | Timer actif du user connecté (custom Provider, `uriTemplate` avec priorité > item route) |
|
||||
|
||||
## Frontend
|
||||
|
||||
### Store Pinia `useTimerStore`
|
||||
|
||||
```typescript
|
||||
state: {
|
||||
activeEntry: TimeEntry | null
|
||||
}
|
||||
|
||||
getters: {
|
||||
isRunning: boolean // activeEntry !== null
|
||||
elapsed: number // calculé via setInterval: now - activeEntry.startedAt
|
||||
}
|
||||
|
||||
actions: {
|
||||
fetchActive() // GET /api/time_entries/active — appelé au chargement app
|
||||
start() // POST à vide (startedAt: now, user: currentUser)
|
||||
startFromTask(task: Task) // Stoppe le timer actif si existant, puis POST avec données du ticket (user = connecté, pas assignee)
|
||||
stop() // PATCH stoppedAt: now
|
||||
}
|
||||
```
|
||||
|
||||
Le temps est fiable même si le navigateur est fermé : `startedAt` est en base, le compteur affiche toujours `now - startedAt` au rechargement.
|
||||
|
||||
### Timer dans la sidebar (bas à gauche)
|
||||
|
||||
- **Inactif** : affiche `00:00:00` + bouton play (démarrage à vide)
|
||||
- **Actif** : compteur temps réel + bouton stop
|
||||
- Toujours visible, dans le layout `default.vue`
|
||||
|
||||
### Bouton play sur TaskCard
|
||||
|
||||
- Bouton play existant sur les cartes du kanban
|
||||
- Clic → `timerStore.startFromTask(task)`
|
||||
- Si un timer est déjà actif : stop automatique de l'ancien, puis démarrage du nouveau
|
||||
|
||||
### Page "Suivi des temps"
|
||||
|
||||
**Route** : `/time-tracking`
|
||||
**Lien sidebar** : "Suivi de temps" (icône horloge)
|
||||
|
||||
#### Header
|
||||
|
||||
- Titre "Suivi des temps"
|
||||
- Mois/année en orange
|
||||
- Toggle vue : **Semaine** / **Jour** avec flèches `< >`
|
||||
- Filtres : **User** (select, défaut = user connecté), **Type** (select TaskType)
|
||||
- Bouton **"+ Ajouter une Activité"**
|
||||
|
||||
#### Grille calendrier
|
||||
|
||||
- **Axe Y** : 00:00 → 23:59 (minuit à minuit)
|
||||
- **Axe X** : 7 colonnes (semaine, Lun→Dim) ou 1 colonne (jour)
|
||||
- Chaque colonne : jour + date + total heures sous la date
|
||||
|
||||
#### Blocs de temps
|
||||
|
||||
- **Couleur** = couleur du projet
|
||||
- **Contenu** : titre, nom du projet (petit), badge type coloré, durée
|
||||
- Les blocs peuvent se chevaucher
|
||||
|
||||
#### Interactions
|
||||
|
||||
| Action | Comportement |
|
||||
|--------|-------------|
|
||||
| **Clic sur un bloc** | Ouvre le drawer en mode édition |
|
||||
| **Drag & drop d'un bloc** | Déplacer vers un autre créneau ou autre jour |
|
||||
| **Resize (bord bas)** | Redimensionner la durée (modifie `stoppedAt`) |
|
||||
| **Clic sur créneau vide** | Ouvre le drawer en mode création avec heure début pré-remplie |
|
||||
| **Clic droit sur un bloc** | Menu contextuel : Copier, Supprimer |
|
||||
| **Clic droit sur créneau vide** | Menu contextuel : Coller (si un bloc copié) |
|
||||
| **Bouton "+ Ajouter une Activité"** | Ouvre le drawer en mode création |
|
||||
|
||||
### Drawer "Ajouter/Modifier un temps"
|
||||
|
||||
Utilise le composant `AppDrawer` existant.
|
||||
|
||||
**Champs** :
|
||||
- Titre (input text)
|
||||
- Description (textarea)
|
||||
- Heure début (datetime picker)
|
||||
- Heure fin (datetime picker)
|
||||
- User (select, défaut = user connecté, peut assigner à un autre)
|
||||
- Projet (select)
|
||||
- Type (select TaskType)
|
||||
- Bouton Enregistrer
|
||||
|
||||
En mode édition : champs pré-remplis avec les données du TimeEntry.
|
||||
|
||||
## Service frontend
|
||||
|
||||
### `useTimeEntryService()`
|
||||
|
||||
```typescript
|
||||
getByDateRange(params: { after: string, before: string, user?: number, types?: number[] }): Promise<TimeEntry[]>
|
||||
getActive(): Promise<TimeEntry | null>
|
||||
create(payload: TimeEntryWrite): Promise<TimeEntry>
|
||||
update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry>
|
||||
remove(id: number): Promise<void>
|
||||
```
|
||||
|
||||
### DTO `TimeEntry`
|
||||
|
||||
```typescript
|
||||
type TimeEntry = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
startedAt: string // ISO datetime
|
||||
stoppedAt: string | null // null = timer actif
|
||||
user: UserData
|
||||
project: Project | null
|
||||
task: Task | null
|
||||
types: TaskType[]
|
||||
}
|
||||
|
||||
type TimeEntryWrite = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
startedAt: string
|
||||
stoppedAt?: string | null
|
||||
user: string // IRI
|
||||
project?: string | null // IRI
|
||||
task?: string | null // IRI
|
||||
types?: string[] // IRIs
|
||||
}
|
||||
```
|
||||
|
||||
## Modifications sur l'existant
|
||||
|
||||
- **DTO `Task`** : ajouter le champ `project: Project` (nécessaire pour `startFromTask`)
|
||||
- **`TaskCard.vue`** : connecter le bouton play existant à `timerStore.startFromTask(task)`
|
||||
- **`default.vue`** : intégrer `SidebarTimer.vue` en bas de la sidebar (au-dessus du bouton collapse). En mode collapsed : afficher uniquement le bouton play/stop sans le compteur texte
|
||||
- **Sidebar links** : ajouter le lien "Suivi de temps" vers `/time-tracking`
|
||||
|
||||
## Composants frontend
|
||||
|
||||
| Composant | Rôle |
|
||||
|-----------|------|
|
||||
| `TimeTrackingCalendar.vue` | Grille calendrier (semaine/jour) avec blocs |
|
||||
| `TimeEntryBlock.vue` | Bloc de temps individuel (drag, resize) |
|
||||
| `TimeEntryDrawer.vue` | Drawer ajout/modification |
|
||||
| `TimeEntryContextMenu.vue` | Menu contextuel (copier, coller, supprimer) |
|
||||
| `SidebarTimer.vue` | Widget timer dans la sidebar |
|
||||
145
docs/superpowers/specs/2026-03-12-task-archiving-design.md
Normal file
145
docs/superpowers/specs/2026-03-12-task-archiving-design.md
Normal file
@@ -0,0 +1,145 @@
|
||||
o# Feature: Archivage de tickets et de groupes
|
||||
|
||||
## Résumé
|
||||
|
||||
Permettre d'archiver des tickets individuels (quand leur statut est final) et des groupes entiers (quand tous leurs tickets sont en statut final). Les éléments archivés disparaissent de la vue kanban et sont consultables via une page dédiée "Archives" dans le projet.
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### TaskStatus — ajout `isFinal`
|
||||
|
||||
- Nouveau champ `isFinal: bool` (default `false`)
|
||||
- Mis à `true` sur le statut "Terminé" dans les fixtures
|
||||
- Exposé en lecture et écriture via API Platform (groupes de sérialisation `task_status:read`, `task_status:write`, et `task:read`)
|
||||
- Permet d'identifier dynamiquement quels statuts autorisent l'archivage
|
||||
|
||||
### Task — ajout `archived`
|
||||
|
||||
- Nouveau champ `archived: bool` (default `false`)
|
||||
- Filtre API Platform `BooleanFilter` sur `archived` pour requêter `?archived=false` ou `?archived=true`
|
||||
- Le kanban charge les tickets avec `archived=false`
|
||||
- La page archives charge les tickets avec `archived=true`
|
||||
|
||||
### TaskGroup — ajout `archived`
|
||||
|
||||
- Nouveau champ `archived: bool` (default `false`)
|
||||
- Filtre API Platform `BooleanFilter` sur `archived`
|
||||
- Le kanban et le filtre groupe n'affichent que les groupes `archived=false`
|
||||
|
||||
### Migration
|
||||
|
||||
Une migration Doctrine unique pour les 3 champs (`task_status.is_final`, `task.archived`, `task_group.archived`).
|
||||
|
||||
## Backend — logique métier
|
||||
|
||||
### Archivage de groupe (bulk)
|
||||
|
||||
L'archivage d'un groupe est une opération frontend multi-appels :
|
||||
|
||||
1. PATCH chaque ticket du groupe avec `{ archived: true }`
|
||||
2. PATCH le groupe avec `{ archived: true }`
|
||||
|
||||
Pas de endpoint custom côté backend — on réutilise les PATCH existants.
|
||||
|
||||
### Permissions
|
||||
|
||||
L'archivage suit le modèle de permissions existant : les opérations PATCH sur Task et TaskGroup requièrent `ROLE_ADMIN`. Pas de règle supplémentaire.
|
||||
|
||||
### Pas de validation backend sur `isFinal`
|
||||
|
||||
La règle "archiver seulement si statut final" est appliquée côté frontend (visibilité du bouton). Pas de State Processor dédié — cohérent avec le reste de l'app qui ne valide pas les transitions de statut côté serveur.
|
||||
|
||||
## Frontend
|
||||
|
||||
### TaskDrawer — archivage et modale suppression
|
||||
|
||||
**Bouton "Archiver"** :
|
||||
|
||||
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
|
||||
- PATCH `{ archived: true }` sur le ticket
|
||||
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
|
||||
- Ferme le drawer et rafraîchit la liste des tickets
|
||||
|
||||
**Bouton "Désarchiver"** :
|
||||
|
||||
- Visible quand on consulte un ticket archivé (depuis la page archives)
|
||||
- PATCH `{ archived: false }`
|
||||
- Ferme le drawer et rafraîchit la page archives
|
||||
|
||||
**Modale de confirmation de suppression** :
|
||||
|
||||
- Déclenchée au clic sur "Supprimer" dans le TaskDrawer
|
||||
- Message : "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||
- Deux boutons : "Annuler" / "Supprimer" (style destructif, rouge)
|
||||
- Suit le pattern existant de `ConfirmDeleteStatusModal`
|
||||
|
||||
### Page Archives — `/projects/[id]/archives`
|
||||
|
||||
- Nouveau sous-onglet "Archives" dans la navigation projet (à côté de "Groupes")
|
||||
- Liste des tickets archivés du projet (`archived=true`)
|
||||
- Colonnes affichées : numéro, titre, statut, groupe, assigné
|
||||
- Clic sur un ticket → ouvre le TaskDrawer (avec bouton "Désarchiver")
|
||||
- Filtre par groupe possible
|
||||
|
||||
### Page Groupes — archivage de groupes
|
||||
|
||||
**Vue par défaut** : affiche uniquement les groupes non archivés.
|
||||
|
||||
**Toggle "Voir les groupes archivés"** : bascule pour afficher les groupes archivés.
|
||||
|
||||
**Bouton "Archiver" sur un groupe** :
|
||||
|
||||
- Visible uniquement si le groupe a au moins un ticket ET que **tous** ses tickets ont un statut `isFinal: true` (un ticket sans statut bloque l'archivage)
|
||||
- Archive tous les tickets du groupe puis le groupe lui-même (appels PATCH séquentiels)
|
||||
- Rafraîchit la liste
|
||||
|
||||
**Bouton "Désarchiver" sur un groupe archivé** :
|
||||
|
||||
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
|
||||
- Rafraîchit la liste
|
||||
|
||||
### Admin — toggle `isFinal` sur les statuts
|
||||
|
||||
- Ajout d'un checkbox/toggle "Statut final" dans l'AdminStatusTab (création et édition de statuts)
|
||||
- Permet aux admins de configurer quels statuts sont considérés comme finaux
|
||||
|
||||
### Kanban — filtrage
|
||||
|
||||
- Le filtre groupe dans le dropdown n'affiche que les groupes `archived=false`
|
||||
- Les tickets `archived=true` sont exclus du kanban
|
||||
|
||||
### Time tracking
|
||||
|
||||
- Les entrées de temps liées à des tickets archivés restent visibles dans les vues time-tracking (pas de changement)
|
||||
|
||||
## DTOs
|
||||
|
||||
### TaskStatus
|
||||
|
||||
Ajout du champ `isFinal: boolean` dans les types `TaskStatus` et `TaskStatusWrite`.
|
||||
|
||||
### Task
|
||||
|
||||
Ajout du champ `archived: boolean` dans les types `Task` et `TaskWrite`.
|
||||
|
||||
### TaskGroup
|
||||
|
||||
Ajout du champ `archived: boolean` dans les types `TaskGroup` et `TaskGroupWrite`.
|
||||
|
||||
## Traductions (i18n)
|
||||
|
||||
Clés à ajouter dans `fr.json` :
|
||||
|
||||
- `task.archive` / `task.unarchive`
|
||||
- `task.delete_confirm_title` / `task.delete_confirm_message`
|
||||
- `group.archive` / `group.unarchive`
|
||||
- `group.show_archived` / `group.hide_archived`
|
||||
- `project.tabs.archives`
|
||||
- `status.is_final`
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Historique/date d'archivage (pourra être ajouté plus tard avec un champ `archivedAt`)
|
||||
- Archivage automatique (cron/scheduler)
|
||||
- Archivage en masse depuis la page archives
|
||||
- Verrouillage des tickets archivés (modification de statut, etc.)
|
||||
151
docs/superpowers/specs/2026-03-13-gitea-integration-design.md
Normal file
151
docs/superpowers/specs/2026-03-13-gitea-integration-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Intégration Gitea — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Lier les tickets Lesstime à Gitea pour :
|
||||
- Créer une branche depuis un ticket (avec choix du type : feature, fix, refactor, etc.)
|
||||
- Voir les branches, commits, PRs et statut CI liés à un ticket
|
||||
- Le tout à la demande (pas de webhook, pas de cron, pas de stockage git en base)
|
||||
|
||||
## Décisions
|
||||
|
||||
| Question | Décision |
|
||||
|----------|----------|
|
||||
| Interaction | Depuis Lesstime uniquement (bouton + affichage) |
|
||||
| Repos | Un projet = un repo Gitea |
|
||||
| Nommage branches | `<type>/PROJ-42-titre-en-slug` (type choisi par l'utilisateur) |
|
||||
| Détection commits | Par la branche (tous les commits d'une branche liée au ticket) |
|
||||
| Données affichées | Branches + commits + PRs + statut CI/CD |
|
||||
| Config serveur | Globale (admin) : URL + token API |
|
||||
| Config repo | Par projet : owner + repo name |
|
||||
| Synchronisation | À la demande (appel API Gitea en temps réel) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
#### Configuration globale — entité `GiteaConfiguration`
|
||||
|
||||
Entité dédiée (singleton en base, un seul enregistrement) :
|
||||
|
||||
```
|
||||
id: int
|
||||
url: string|null (ex: "https://git.malio.fr")
|
||||
token: string|null (token API personnel Gitea, chiffré via SodiumEncryptor)
|
||||
```
|
||||
|
||||
Le token est chiffré au repos avec une clé symétrique définie en variable d'environnement (`GITEA_ENCRYPTION_KEY`). L'endpoint GET ne retourne jamais le token en clair — seulement un booléen `hasToken`.
|
||||
|
||||
#### Entité `Project` — nouveaux champs
|
||||
|
||||
```
|
||||
gitea_owner: string|null (ex: "malio")
|
||||
gitea_repo: string|null (ex: "lesstime")
|
||||
```
|
||||
|
||||
Nullable car tous les projets ne sont pas forcément liés à un repo.
|
||||
|
||||
#### Service `GiteaApiService`
|
||||
|
||||
Service Symfony qui encapsule les appels HTTP vers l'API Gitea REST v1.
|
||||
|
||||
**Configuration HTTP :**
|
||||
- Timeout : 10s par requête
|
||||
- En cas d'erreur Gitea (timeout, 5xx, réseau), le service lève une `GiteaApiException` avec un message clair. Le frontend affiche un état dégradé (message d'erreur dans la section Git, le reste de la TaskModal fonctionne normalement).
|
||||
|
||||
**Méthodes :**
|
||||
- `testConnection(): bool` — appelle `GET /api/v1/version` pour vérifier la connexion
|
||||
- `listRepositories(): array` — liste les repos accessibles (pour sélection dans le projet)
|
||||
- `getDefaultBranch(project): string` — récupère la branche par défaut du repo via `GET /api/v1/repos/{owner}/{repo}`
|
||||
- `createBranch(project, task, type, baseBranch): string` — crée une branche `<type>/CODE-NUM-slug` à partir d'une branche de base
|
||||
- `listBranches(project, taskCode): array` — liste les branches matchant le pattern `*/CODE-NUM-*` ou `*/CODE-NUM` (délimité pour éviter de matcher PROJ-420 quand on cherche PROJ-42)
|
||||
- `listCommits(project, branch): array` — liste les commits d'une branche (paginé, max 30)
|
||||
- `listPullRequests(project, taskCode): array` — liste les PRs en filtrant par `head` branch (paramètre `?head=` supporté par Gitea)
|
||||
- `getPullRequestChecks(project, prNumber): array` — statut CI/CD d'une PR
|
||||
- `copyBranchName(task, type): string` — génère le nom de branche sans appeler Gitea (pour le bouton "copier")
|
||||
|
||||
**Slug :** généré côté backend avec `Symfony\Component\String\Slugger\AsciiSlugger` — gère les accents français, tronqué à 50 caractères max pour le slug (le nom complet de branche reste sous 80 chars).
|
||||
|
||||
#### Endpoints API Platform
|
||||
|
||||
Nouveaux endpoints :
|
||||
|
||||
- `GET /api/settings/gitea` — récupérer la config Gitea (admin only, retourne url + hasToken)
|
||||
- `PUT /api/settings/gitea` — sauvegarder la config Gitea (admin only)
|
||||
- `POST /api/settings/gitea/test` — tester la connexion Gitea (admin only)
|
||||
- `GET /api/gitea/repositories` — lister les repos disponibles (pour config projet)
|
||||
- `POST /api/tasks/{id}/gitea/branches` — créer une branche pour un ticket
|
||||
- `GET /api/tasks/{id}/gitea/branches` — lister branches liées au ticket
|
||||
- `GET /api/tasks/{id}/gitea/pull-requests` — lister PRs liées au ticket (avec statut CI inclus)
|
||||
|
||||
Les endpoints branches et PRs sont séparés pour permettre un chargement progressif côté frontend et éviter de fan-out trop de requêtes Gitea en un seul appel.
|
||||
|
||||
### Frontend
|
||||
|
||||
#### Service `frontend/services/gitea.ts`
|
||||
|
||||
Nouveau service API encapsulant les appels, cohérent avec le pattern existant (`frontend/services/`).
|
||||
|
||||
#### Admin — Config Gitea
|
||||
|
||||
Nouveau tab `GiteaAdminTab.vue` dans l'admin pour configurer :
|
||||
- URL du serveur Gitea
|
||||
- Token API (champ password, affiche seulement si un token est configuré)
|
||||
- Bouton "Tester la connexion"
|
||||
|
||||
#### ProjectDrawer — Config repo
|
||||
|
||||
Ajout de champs dans le drawer de projet :
|
||||
- Sélecteur de repo Gitea (dropdown alimenté par `GET /api/gitea/repositories`)
|
||||
- Affiche `owner/repo` une fois sélectionné
|
||||
|
||||
#### TaskModal — Section Git
|
||||
|
||||
Nouveau composant `TaskGitSection.vue` intégré dans la TaskModal (visible et chargé uniquement si le projet a un repo configuré).
|
||||
|
||||
**Bouton "Créer une branche"** :
|
||||
- Sélecteur de type : `feature`, `fix`, `refactor`, `hotfix`, `chore`
|
||||
- Sélecteur de branche de base (default: branche par défaut du repo)
|
||||
- Preview du nom : `feature/PROJ-42-titre-de-la-tache`
|
||||
- Bouton de confirmation
|
||||
- Bouton "Copier le nom" (génère le nom sans appeler Gitea, pour création locale)
|
||||
|
||||
**Affichage des infos Git** (chargement progressif) :
|
||||
- Liste des branches liées (avec statut : active / mergée / supprimée)
|
||||
- Pour chaque branche : derniers commits (hash court, message, auteur, date)
|
||||
- PRs associées (titre, statut : open/merged/closed, reviewers)
|
||||
- Statut CI/CD par PR (checks : success/failure/pending)
|
||||
- Liens directs vers Gitea pour chaque élément
|
||||
- En cas d'erreur Gitea : message d'erreur dans la section, le reste de la modal reste fonctionnel
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Le token Gitea est chiffré en base via `SodiumEncryptor` avec clé `GITEA_ENCRYPTION_KEY`
|
||||
- L'endpoint `GET /api/settings/gitea` retourne `url` + `hasToken: bool`, jamais le token en clair
|
||||
- Seuls les `ROLE_ADMIN` peuvent configurer le serveur Gitea et les repos
|
||||
- Les utilisateurs authentifiés peuvent créer des branches et voir les infos git pour les tâches qu'ils ont le droit de voir
|
||||
|
||||
### i18n
|
||||
|
||||
Toutes les chaînes UI (labels, messages d'erreur, types de branche) passent par le système i18n existant (`frontend/i18n/locales/fr.json` et `en.json`).
|
||||
|
||||
## API Gitea — Endpoints utilisés
|
||||
|
||||
| Action | Méthode Gitea API |
|
||||
|--------|-------------------|
|
||||
| Tester connexion | `GET /api/v1/version` |
|
||||
| Info repo (branche défaut) | `GET /api/v1/repos/{owner}/{repo}` |
|
||||
| Lister repos | `GET /api/v1/repos/search` |
|
||||
| Lister branches | `GET /api/v1/repos/{owner}/{repo}/branches` |
|
||||
| Créer branche | `POST /api/v1/repos/{owner}/{repo}/branches` |
|
||||
| Lister commits | `GET /api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=30` |
|
||||
| Lister PRs par branche | `GET /api/v1/repos/{owner}/{repo}/pulls?state=all&head={branch}` |
|
||||
| Statut CI | `GET /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` |
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Webhooks Gitea → Lesstime
|
||||
- Stockage des commits/PRs en base
|
||||
- Création de PR depuis Lesstime
|
||||
- Lien multi-repo par projet
|
||||
- Synchronisation périodique (cron/polling)
|
||||
135
docs/superpowers/specs/2026-03-13-my-tasks-page-design.md
Normal file
135
docs/superpowers/specs/2026-03-13-my-tasks-page-design.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Feature: Page "Mes tâches"
|
||||
|
||||
## Résumé
|
||||
|
||||
Page dédiée `/my-tasks` affichant toutes les tâches non-archivées de tous les projets, avec filtrage côté serveur. Deux vues : Kanban (colonnes par statut) et Liste. Par défaut filtrée sur l'utilisateur courant, avec possibilité de changer l'assigné, voir tous les utilisateurs, et filtrer par projet/groupe/type/priorité/effort.
|
||||
|
||||
## Backend
|
||||
|
||||
### Filtres API Platform sur Task
|
||||
|
||||
Ajouter des `SearchFilter` sur l'entité `Task` pour les champs suivants (en plus des filtres existants `project`, `group`, `archived`) :
|
||||
|
||||
- `assignee` — filtre exact (par id)
|
||||
- `priority` — filtre exact
|
||||
- `effort` — filtre exact
|
||||
- `tags` — filtre exact (ManyToMany, query param format : `tags[]=/api/task_tags/1`)
|
||||
- `status` — filtre exact
|
||||
|
||||
Désactiver la pagination sur l'opération `GetCollection` de Task (`paginationEnabled: false`) pour charger toutes les tâches filtrées en un seul appel.
|
||||
|
||||
Aucune migration nécessaire — les filtres sont purement déclaratifs sur des relations existantes.
|
||||
|
||||
Exemple d'appel :
|
||||
```
|
||||
GET /api/tasks?assignee=/api/users/1&archived=false&project=/api/projects/2&tags[]=/api/task_tags/3
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Nouvelle page : `frontend/pages/my-tasks.vue`
|
||||
|
||||
#### Barre de filtres
|
||||
|
||||
Une ligne horizontale de `MalioSelect` :
|
||||
|
||||
| Filtre | Options | Défaut |
|
||||
|--------|---------|--------|
|
||||
| Projet | Tous les projets de l'utilisateur | Tous |
|
||||
| Groupe | Groupes (filtrés par projet sélectionné si applicable) | Tous |
|
||||
| Type (tags) | Tous les tags | Tous |
|
||||
| Priorité | Toutes les priorités | Tous |
|
||||
| Effort | Tous les efforts | Tous |
|
||||
| Assigné | Tous les utilisateurs + option "Tous" | Utilisateur courant |
|
||||
|
||||
#### Toggle de vue
|
||||
|
||||
Deux boutons icônes en haut à droite (à côté des filtres) :
|
||||
- Icône grille/kanban → vue Kanban
|
||||
- Icône liste → vue Liste
|
||||
|
||||
État persisté en localStorage ou simple ref (pas critique).
|
||||
|
||||
#### Vue Kanban
|
||||
|
||||
- Colonnes par statut (ordonnées par `position`), même layout que `projects/[id]/index.vue`
|
||||
- Chaque colonne contient les `TaskCard` filtrés ayant ce statut
|
||||
- Les tâches sans statut vont dans une section "Backlog" en première colonne
|
||||
- Les colonnes de statut sans tâches sont affichées (cohérent avec la vue projet)
|
||||
- Pas de drag-and-drop (pas pertinent en contexte cross-projet)
|
||||
|
||||
#### Vue Liste
|
||||
|
||||
- Lignes de tâches avec :
|
||||
- Titre (bold)
|
||||
- Badges : priorité (couleur) + tags (couleur)
|
||||
- Code projet + numéro de tâche (ex: `SIRH-1`) — aligné à droite
|
||||
- Icône timer (comme sur les TaskCard)
|
||||
- Pas d'affichage des temps (exclu du périmètre)
|
||||
- Séparation visuelle légère entre les lignes (border-bottom ou alternance de fond)
|
||||
|
||||
#### Chargement des données
|
||||
|
||||
Au montage, chargement parallèle :
|
||||
```typescript
|
||||
const [tasks, statuses, projects, efforts, priorities, tags, groups, users] = await Promise.all([
|
||||
taskService.getFiltered({ assignee: currentUserId, archived: false }),
|
||||
statusService.getAll(),
|
||||
projectService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll()
|
||||
])
|
||||
```
|
||||
|
||||
À chaque changement de filtre → nouvel appel API pour les tâches uniquement. Les données de référence (statuts, projets, etc.) ne sont chargées qu'une fois.
|
||||
|
||||
#### Interactions
|
||||
|
||||
- Clic sur une TaskCard ou ligne de liste → ouvre le `TaskModal` en mode édition
|
||||
- Après save/delete dans le modal → rechargement des tâches
|
||||
- Timer sur les cartes/lignes fonctionne via le `useTimerStore` existant
|
||||
|
||||
### Service tasks — nouvelle méthode
|
||||
|
||||
Ajout dans `frontend/services/tasks.ts` :
|
||||
|
||||
```typescript
|
||||
getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]>
|
||||
```
|
||||
|
||||
Construit les query params à partir de l'objet et appelle `GET /api/tasks?...`. Convertit les ids en IRIs si nécessaire. Gère les paramètres tableau pour les relations ManyToMany (`tags[]`).
|
||||
|
||||
### Navigation
|
||||
|
||||
Nouveau `SidebarLink` dans `frontend/layouts/default.vue` :
|
||||
- Label : "Mes tâches"
|
||||
- Icône : `mdi:clipboard-check-outline` (ou similaire)
|
||||
- Position : entre "Tableau de bord" et "Projets"
|
||||
- Route : `/my-tasks`
|
||||
|
||||
### Traductions (i18n)
|
||||
|
||||
Clés à ajouter dans `fr.json` :
|
||||
- `myTasks.title` : "Mes tâches"
|
||||
- `myTasks.viewKanban` : "Vue Kanban"
|
||||
- `myTasks.viewList` : "Vue Liste"
|
||||
- `myTasks.allProjects` : "Tous les projets"
|
||||
- `myTasks.allGroups` : "Tous les groupes"
|
||||
- `myTasks.allTypes` : "Tous les types"
|
||||
- `myTasks.allPriorities` : "Toutes les priorités"
|
||||
- `myTasks.allEfforts` : "Tous les efforts"
|
||||
- `myTasks.allAssignees` : "Tous"
|
||||
- `myTasks.noTasks` : "Aucune tâche"
|
||||
- `myTasks.backlog` : "Backlog"
|
||||
- Sidebar : `sidebar.myTasks` : "Mes tâches"
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Drag-and-drop entre colonnes (pas pertinent cross-projet)
|
||||
- Affichage des temps/durées sur les tâches
|
||||
- Pagination (à ajouter plus tard si nécessaire)
|
||||
- Création de tâche depuis cette page (utiliser la vue projet pour ça)
|
||||
- Recherche texte (pourra être ajoutée plus tard)
|
||||
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal file
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# BookStack Connector — Design Spec
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**BookStack version:** v25.12.8
|
||||
**Pattern:** Mirror of Gitea connector
|
||||
|
||||
## Overview
|
||||
|
||||
Connecteur BookStack permettant de lier des documents (pages et livres) du wiki à des tâches Lesstime. Chaque projet peut être associé à une étagère (shelf) BookStack, et les utilisateurs peuvent rechercher et lier des pages/livres de cette étagère à leurs tâches.
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Types liés : **pages** et **livres** (books)
|
||||
- Niveau projet : liaison à une **étagère** (shelf)
|
||||
- Niveau tâche : liaison à une ou plusieurs **pages/livres** de l'étagère du projet
|
||||
- Recherche : filtrée dans l'étagère du projet uniquement
|
||||
- Stockage : **référence** (titre + URL), pas d'aperçu du contenu
|
||||
- Auth BookStack : Token ID + Token Secret (header `Authorization: Token {id}:{secret}`)
|
||||
|
||||
## Backend
|
||||
|
||||
### Entités
|
||||
|
||||
#### BookStackConfiguration (singleton)
|
||||
|
||||
```php
|
||||
// src/Entity/BookStackConfiguration.php
|
||||
class BookStackConfiguration
|
||||
{
|
||||
private ?int $id;
|
||||
private ?string $url = null;
|
||||
private ?string $encryptedTokenId = null;
|
||||
private ?string $encryptedTokenSecret = null;
|
||||
|
||||
public function hasToken(): bool; // vérifie que les deux sont présents
|
||||
}
|
||||
```
|
||||
|
||||
- Chiffrement via `TokenEncryptor` existant (même pattern que Gitea)
|
||||
- Repository avec `findSingleton()`
|
||||
|
||||
#### TaskBookStackLink
|
||||
|
||||
```php
|
||||
// src/Entity/TaskBookStackLink.php
|
||||
class TaskBookStackLink
|
||||
{
|
||||
private ?int $id;
|
||||
private Task $task; // ManyToOne, CASCADE on delete
|
||||
private int $bookstackId; // ID dans BookStack
|
||||
private string $bookstackType; // 'page' | 'book'
|
||||
private string $title; // titre au moment du lien (cache)
|
||||
private string $url; // URL complète
|
||||
private \DateTimeImmutable $createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
#### Project (extension)
|
||||
|
||||
Ajout de deux champs :
|
||||
- `bookstackShelfId` (nullable int)
|
||||
- `bookstackShelfName` (nullable string) — cache du nom pour affichage
|
||||
|
||||
### Service
|
||||
|
||||
#### BookStackApiService
|
||||
|
||||
```php
|
||||
// src/Service/BookStackApiService.php
|
||||
class BookStackApiService
|
||||
{
|
||||
public function testConnection(): bool;
|
||||
public function listShelves(): array;
|
||||
public function searchInShelf(int $shelfId, string $query): array;
|
||||
public function getPage(int $id): array;
|
||||
public function getBook(int $id): array;
|
||||
}
|
||||
```
|
||||
|
||||
- Utilise `HttpClientInterface` (Symfony HttpClient)
|
||||
- Auth : header `Authorization: Token {tokenId}:{tokenSecret}`
|
||||
- Timeout : 10 secondes
|
||||
- `testConnection()` : GET `/api/docs.json`
|
||||
- `listShelves()` : GET `/api/shelves` (paginé via `count`/`offset`, pas `page`/`limit` — spécificité BookStack)
|
||||
- `searchInShelf()` : algorithme en 3 étapes :
|
||||
1. GET `/api/shelves/{shelfId}` → récupère la liste des `books` de l'étagère (IDs)
|
||||
2. GET `/api/search?query={query} {type:page|book}` → recherche globale (espace entre query et filtre type, BookStack syntax)
|
||||
3. Filtre côté PHP : pour les **books**, vérifie que `book.id` est dans la liste de l'étagère ; pour les **pages**, vérifie que `page.book_id` est dans la liste. Exclut les résultats `chapter` et `bookshelf`.
|
||||
- Note : la liste des books de l'étagère peut être cachée en mémoire pour la durée de la requête.
|
||||
- `getPage()` : GET `/api/pages/{id}`
|
||||
- `getBook()` : GET `/api/books/{id}`
|
||||
|
||||
#### BookStackApiException
|
||||
|
||||
```php
|
||||
// src/Exception/BookStackApiException.php
|
||||
class BookStackApiException extends \RuntimeException {}
|
||||
```
|
||||
|
||||
### API Resources & Endpoints
|
||||
|
||||
#### Admin
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
|
||||
| PUT | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
|
||||
| POST | `/api/settings/bookstack/test` | BookStackTestConnection | ROLE_ADMIN |
|
||||
|
||||
**BookStackSettings** (DTO) :
|
||||
- Read : `url`, `hasToken`
|
||||
- Write : `url`, `tokenId`, `tokenSecret`
|
||||
|
||||
**BookStackTestConnection** (DTO) :
|
||||
- Read : `success`
|
||||
|
||||
#### Projet
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/bookstack/shelves` | BookStackShelf | ROLE_ADMIN |
|
||||
|
||||
**BookStackShelf** (DTO) :
|
||||
- Read : `id`, `name`
|
||||
|
||||
L'étagère sélectionnée est sauvée via le PATCH existant de Project (`bookstackShelfId`, `bookstackShelfName`).
|
||||
|
||||
#### Tâche
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
|
||||
| POST | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
|
||||
| DELETE | `/api/tasks/{taskId}/bookstack/links/{id}` | BookStackLink | Authenticated |
|
||||
| GET | `/api/tasks/{taskId}/bookstack/search?q=` | BookStackSearchResult | Authenticated |
|
||||
|
||||
**BookStackLink** (DTO) :
|
||||
- Read : `id`, `bookstackId`, `bookstackType`, `title`, `url`, `createdAt`
|
||||
- Write : `bookstackId`, `bookstackType`, `title`, `url`
|
||||
|
||||
**BookStackSearchResult** (DTO) :
|
||||
- Read : `id`, `type`, `name`, `url`
|
||||
|
||||
### State Providers / Processors
|
||||
|
||||
| Classe | Rôle |
|
||||
|--------|------|
|
||||
| `BookStackSettingsProvider` | Lit config singleton, retourne DTO masqué |
|
||||
| `BookStackSettingsProcessor` | Persiste config, chiffre tokens |
|
||||
| `BookStackTestConnectionProvider` | Appelle `testConnection()` |
|
||||
| `BookStackShelfProvider` | Appelle `listShelves()`, mappe en DTOs |
|
||||
| `BookStackLinkProvider` | Lit `TaskBookStackLink` par task ID |
|
||||
| `BookStackLinkProcessor` | POST : crée lien en DB / DELETE : supprime |
|
||||
| `BookStackSearchResultProvider` | Appelle `searchInShelf()`, mappe en DTOs |
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
CREATE TABLE bookstack_configuration (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
url VARCHAR(255) DEFAULT NULL,
|
||||
encrypted_token_id TEXT DEFAULT NULL,
|
||||
encrypted_token_secret TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE task_bookstack_link (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
task_id INT NOT NULL REFERENCES task(id) ON DELETE CASCADE,
|
||||
bookstack_id INT NOT NULL,
|
||||
bookstack_type VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IDX_task_bookstack_link_task_id ON task_bookstack_link (task_id);
|
||||
CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_bookstack_link (task_id, bookstack_id, bookstack_type);
|
||||
|
||||
ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL;
|
||||
ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL;
|
||||
```
|
||||
|
||||
### Variable d'environnement
|
||||
|
||||
Prérequis : renommer `GITEA_ENCRYPTION_KEY` en `ENCRYPTION_KEY` (générique) dans `TokenEncryptor`, `.env`, et `docker/.env.docker`. Mettre à jour le message d'erreur dans `TokenEncryptor`. Cela permet de réutiliser le même service pour chiffrer les tokens BookStack (deux appels `encrypt()`/`decrypt()` : un pour tokenId, un pour tokenSecret).
|
||||
|
||||
### Notes techniques
|
||||
|
||||
- `BookStackTestConnectionProvider` implémente à la fois `ProviderInterface` et `ProcessorInterface` (même pattern que `GiteaTestConnectionProvider`)
|
||||
- Les endpoints collection du frontend utilisent `extractHydraMembers()` pour extraire les résultats des réponses Hydra
|
||||
- Les titres/URLs stockés dans `TaskBookStackLink` sont des snapshots au moment du lien — pas de rafraîchissement automatique (intentionnel)
|
||||
- Le select étagère dans `ProjectDrawer` n'est affiché que pour les admins (endpoint `ROLE_ADMIN`)
|
||||
|
||||
## Frontend
|
||||
|
||||
### Service
|
||||
|
||||
```typescript
|
||||
// frontend/services/bookstack.ts
|
||||
export function useBookStackService() {
|
||||
// Admin
|
||||
async function getSettings(): Promise<BookStackSettings>
|
||||
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings>
|
||||
async function testConnection(): Promise<BookStackTestResult>
|
||||
|
||||
// Projet
|
||||
async function listShelves(): Promise<BookStackShelf[]>
|
||||
|
||||
// Tâche
|
||||
async function getLinks(taskId: number): Promise<BookStackLink[]>
|
||||
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink>
|
||||
async function removeLink(taskId: number, linkId: number): Promise<void>
|
||||
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]>
|
||||
}
|
||||
```
|
||||
|
||||
### DTOs
|
||||
|
||||
```typescript
|
||||
// frontend/services/dto/bookstack.ts
|
||||
type BookStackSettings = { url: string | null; hasToken: boolean }
|
||||
type BookStackSettingsWrite = { url: string | null; tokenId: string | null; tokenSecret: string | null }
|
||||
type BookStackTestResult = { success: boolean }
|
||||
type BookStackShelf = { id: number; name: string }
|
||||
type BookStackLink = { id: number; bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string; createdAt: string }
|
||||
type BookStackLinkCreate = { bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string }
|
||||
type BookStackSearchResult = { id: number; type: 'page' | 'book'; name: string; url: string }
|
||||
```
|
||||
|
||||
### Composants
|
||||
|
||||
#### AdminBookStackTab.vue
|
||||
|
||||
Onglet admin (même pattern que `AdminGiteaTab.vue`) :
|
||||
- Champs : URL, Token ID, Token Secret
|
||||
- Bouton "Tester la connexion" avec indicateur résultat
|
||||
- Indicateur "Token configuré" (ne montre jamais le token)
|
||||
- Sauvegarde via `saveSettings()`
|
||||
|
||||
#### ProjectDrawer.vue (extension)
|
||||
|
||||
- Si BookStack est configuré : select pour choisir une étagère
|
||||
- Charge `listShelves()` à l'ouverture
|
||||
- Sauvegarde `bookstackShelfId` + `bookstackShelfName` sur le projet via PATCH
|
||||
|
||||
#### TaskBookStackLinks.vue
|
||||
|
||||
Petit composant intégré dans `TaskModal.vue`, visible directement :
|
||||
- **Input de recherche** avec debounce (~300ms) → appel `search(taskId, query)` → dropdown résultats
|
||||
- Chaque résultat : icône (page 📄 / livre 📕) + titre — clic pour ajouter
|
||||
- **Liste des liens** sous le champ recherche : icône type + titre cliquable (ouvre BookStack dans nouvel onglet) + bouton × supprimer
|
||||
- Affiché uniquement si le projet de la tâche a une shelf BookStack configurée
|
||||
- Charge les liens existants au mount via `getLinks(taskId)`
|
||||
|
||||
#### TaskModal.vue (extension)
|
||||
|
||||
- Ajoute `<TaskBookStackLinks>` dans le modal, conditionné par `project.bookstackShelfId`
|
||||
- Passe `taskId` et `projectId` en props
|
||||
|
||||
## Fichiers à créer/modifier
|
||||
|
||||
### Backend — Nouveaux fichiers
|
||||
|
||||
```
|
||||
src/Entity/BookStackConfiguration.php
|
||||
src/Entity/TaskBookStackLink.php
|
||||
src/Repository/BookStackConfigurationRepository.php
|
||||
src/Repository/TaskBookStackLinkRepository.php
|
||||
src/Service/BookStackApiService.php
|
||||
src/Exception/BookStackApiException.php
|
||||
src/ApiResource/BookStackSettings.php
|
||||
src/ApiResource/BookStackTestConnection.php
|
||||
src/ApiResource/BookStackShelf.php
|
||||
src/ApiResource/BookStackLink.php
|
||||
src/ApiResource/BookStackSearchResult.php
|
||||
src/State/BookStackSettingsProvider.php
|
||||
src/State/BookStackSettingsProcessor.php
|
||||
src/State/BookStackTestConnectionProvider.php
|
||||
src/State/BookStackShelfProvider.php
|
||||
src/State/BookStackLinkProvider.php
|
||||
src/State/BookStackLinkProcessor.php
|
||||
src/State/BookStackSearchResultProvider.php
|
||||
migrations/VersionXXXX.php
|
||||
```
|
||||
|
||||
### Backend — Fichiers modifiés
|
||||
|
||||
```
|
||||
src/Entity/Project.php (ajout bookstackShelfId, bookstackShelfName)
|
||||
src/Service/TokenEncryptor.php (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
|
||||
```
|
||||
|
||||
### Config — Fichiers modifiés
|
||||
|
||||
```
|
||||
.env (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
|
||||
```
|
||||
|
||||
> Note : `docker/.env.docker` ne contient pas `GITEA_ENCRYPTION_KEY`. Les développeurs utilisant `docker/.env.docker.local` doivent le mettre à jour manuellement.
|
||||
|
||||
### Frontend — Nouveaux fichiers
|
||||
|
||||
```
|
||||
frontend/services/bookstack.ts
|
||||
frontend/services/dto/bookstack.ts
|
||||
frontend/components/admin/AdminBookStackTab.vue
|
||||
frontend/components/task/TaskBookStackLinks.vue
|
||||
```
|
||||
|
||||
### Frontend — Fichiers modifiés
|
||||
|
||||
```
|
||||
frontend/components/task/TaskModal.vue (ajout TaskBookStackLinks)
|
||||
frontend/components/project/ProjectDrawer.vue (ajout select étagère)
|
||||
frontend/components/admin/ (ajout onglet BookStack dans la page admin)
|
||||
```
|
||||
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal file
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Portail Client — Design Spec
|
||||
|
||||
## Résumé
|
||||
|
||||
Ajout d'un portail client dans Lesstime permettant aux utilisateurs-clients de soumettre des tickets (bug, amélioration, autre) sur leurs projets, suivre l'évolution de leur traitement, et joindre des documents. Les utilisateurs internes (ROLE_ADMIN, ROLE_USER) gèrent les tickets côté admin et peuvent les lier manuellement à des tasks existantes. Un système de notifications in-app informe les parties prenantes des événements clés.
|
||||
|
||||
## Décisions d'architecture
|
||||
|
||||
- **ClientTicket est une entité séparée de Task** — cycle de vie indépendant, meilleure séparation de sécurité, maintenance simplifiée
|
||||
- **Même application, vue adaptée par rôle** — pas de portail séparé. ROLE_CLIENT voit les pages `/portal`, ROLE_ADMIN/ROLE_USER voit l'app interne
|
||||
- **Pas de commentaires/échanges** — communication unidirectionnelle : le client soumet, voit les changements de statut, c'est tout
|
||||
- **Notifications in-app uniquement** — pas d'email pour le moment
|
||||
- **Lien ticket-task manuel** — le manager crée des tasks et les lie explicitement à un ticket client
|
||||
- **TaskDocument conservée** — l'entité `TaskDocument` n'est pas renommée, elle est généralisée avec un champ `clientTicket` nullable
|
||||
- **Français uniquement** — l'interface est en français pour le moment, l'anglais pourra être ajouté plus tard
|
||||
|
||||
## Prérequis : sécurisation des endpoints existants
|
||||
|
||||
Avant l'introduction du rôle `ROLE_CLIENT`, il faut sécuriser l'application existante.
|
||||
|
||||
### Modification de `User::getRoles()`
|
||||
|
||||
Actuellement, `User::getRoles()` ajoute inconditionnellement `ROLE_USER` à tous les utilisateurs. Un utilisateur `ROLE_CLIENT` hériterait donc de `ROLE_USER` et pourrait accéder à toutes les données internes.
|
||||
|
||||
**Correction** : `getRoles()` doit ajouter `ROLE_USER` uniquement si l'utilisateur n'a PAS le rôle `ROLE_CLIENT` :
|
||||
|
||||
```php
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
if (!in_array('ROLE_CLIENT', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
|
||||
return array_unique($roles);
|
||||
}
|
||||
```
|
||||
|
||||
### Ajout de `security` sur les endpoints existants
|
||||
|
||||
Les endpoints existants suivants n'ont pas d'annotation `security` explicite et doivent recevoir `security: "is_granted('ROLE_USER')"` sur leurs opérations `GetCollection` et `Get` :
|
||||
|
||||
| Entité | Opérations à sécuriser |
|
||||
|--------|----------------------|
|
||||
| `Task` | GetCollection, Get |
|
||||
| `Project` | GetCollection, Get |
|
||||
| `Client` | GetCollection, Get |
|
||||
| `TaskStatus` | GetCollection, Get |
|
||||
| `TaskEffort` | GetCollection, Get |
|
||||
| `TaskPriority` | GetCollection, Get |
|
||||
| `TaskTag` | GetCollection, Get |
|
||||
| `TaskGroup` | GetCollection, Get |
|
||||
| `TimeEntry` | GetCollection, Get |
|
||||
|
||||
Cela garantit qu'un utilisateur `ROLE_CLIENT` ne peut pas accéder aux ressources internes via l'API.
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Entité `ClientTicket`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `number` | int | Auto-généré, unique par projet (voir stratégie ci-dessous) |
|
||||
| `type` | string (enum) | `bug`, `improvement`, `other` |
|
||||
| `title` | string | Requis |
|
||||
| `description` | text | Requis |
|
||||
| `url` | string (nullable) | Affiché uniquement si `type = bug` |
|
||||
| `status` | string (enum) | `new`, `in_progress`, `done`, `rejected` |
|
||||
| `statusComment` | text (nullable) | Commentaire du manager lors d'un changement de statut |
|
||||
| `project` | ManyToOne → Project | Requis |
|
||||
| `submittedBy` | ManyToOne → User (nullable) | L'utilisateur-client ayant soumis le ticket. **ON DELETE SET NULL** — ne pas détruire l'historique lors de la suppression d'un utilisateur |
|
||||
| `createdAt` | DateTimeImmutable | Auto |
|
||||
| `updatedAt` | DateTimeImmutable | Auto |
|
||||
|
||||
#### Stratégie de numérotation
|
||||
|
||||
Numéro incrémental par projet : `SELECT MAX(number) + 1 FROM client_ticket WHERE project_id = :project`. Contrainte unique sur `(project_id, number)` avec retry en cas de conflit (concurrent insert). Le numéro affiché sera formaté `CT-001`, `CT-002`, etc. en frontend.
|
||||
|
||||
### Statuts des tickets (enum fixe, non configurable)
|
||||
|
||||
| Statut | Description |
|
||||
|--------|-------------|
|
||||
| `new` | Ticket venant d'être soumis |
|
||||
| `in_progress` | Pris en charge par un manager |
|
||||
| `done` | Résolu, client notifié |
|
||||
| `rejected` | Non retenu — `statusComment` obligatoire |
|
||||
|
||||
#### Transitions de statut autorisées
|
||||
|
||||
Toutes les transitions sont autorisées, **sauf** :
|
||||
- `done` → `new` (interdit)
|
||||
- `rejected` → `new` (interdit)
|
||||
|
||||
Un ticket `done` peut repasser en `in_progress` si besoin. Un ticket `rejected` peut passer en `in_progress`. Le Processor valide les transitions et rejette les transitions interdites.
|
||||
|
||||
### Entité `Notification`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `user` | ManyToOne → User | Destinataire |
|
||||
| `type` | string | `ticket_created`, `ticket_status_changed` |
|
||||
| `title` | string | Titre court |
|
||||
| `message` | text | Contenu |
|
||||
| `relatedTicket` | ManyToOne → ClientTicket (nullable) | Lien vers le ticket concerné |
|
||||
| `isRead` | bool | `false` par défaut |
|
||||
| `createdAt` | DateTimeImmutable | Auto |
|
||||
|
||||
### Modifications sur `User`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `client` | ManyToOne → Client (nullable) | `null` = utilisateur interne, set = utilisateur-client |
|
||||
| `allowedProjects` | ManyToMany → Project | Projets auxquels l'utilisateur-client a accès |
|
||||
|
||||
Nouveau rôle : `ROLE_CLIENT`
|
||||
|
||||
#### Groupes de sérialisation
|
||||
|
||||
| Champ | Groupes |
|
||||
|-------|---------|
|
||||
| `client` | `me:read`, `user:read`, `user:write` |
|
||||
| `allowedProjects` | `me:read`, `user:read`, `user:write` |
|
||||
|
||||
Règles :
|
||||
- Plusieurs utilisateurs par client (1+)
|
||||
- Les utilisateurs-clients sont assignés à des projets spécifiques (pas tous les projets du client)
|
||||
- L'admin crée les comptes utilisateurs-clients (pas d'auto-inscription)
|
||||
|
||||
### Modifications sur `Task`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `clientTicket` | ManyToOne → ClientTicket (nullable) | Lien vers un ticket client |
|
||||
|
||||
Le champ `clientTicket` est exposé dans le groupe `task:read` avec les informations de base du ticket (number, type, status, title). Cela permet aux utilisateurs ROLE_USER d'afficher l'icône et le tooltip dans le kanban sans avoir accès à la collection `/api/client_tickets`.
|
||||
|
||||
### Généralisation de `TaskDocument`
|
||||
|
||||
L'entité `TaskDocument` existante est **conservée** (pas de renommage) et généralisée avec un champ supplémentaire :
|
||||
|
||||
| Champ | Modification |
|
||||
|-------|-------------|
|
||||
| `task` | Devient nullable |
|
||||
| `clientTicket` | ManyToOne → ClientTicket (nullable) — ajouté |
|
||||
|
||||
**Contrainte** : au moins un des deux champs `task` / `clientTicket` doit être renseigné (CHECK constraint en base).
|
||||
|
||||
**Processor** : généralisé pour accepter `task` OU `clientTicket` dans le FormData.
|
||||
|
||||
**Sécurité** :
|
||||
- ROLE_ADMIN : accès complet à tous les documents
|
||||
- ROLE_USER : accès aux documents liés à une task (`task IS NOT NULL`)
|
||||
- ROLE_CLIENT : accès aux documents liés à un ticket dont l'utilisateur est le `submittedBy`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Préfixe `/api`.
|
||||
|
||||
### ClientTicket
|
||||
|
||||
| Méthode | Route | Accès | Notes |
|
||||
|---------|-------|-------|-------|
|
||||
| `GET` | `/api/client_tickets` | ROLE_CLIENT : ses propres tickets ; ROLE_ADMIN : tous | Filtres : `project`, `status`, `submittedBy` |
|
||||
| `GET` | `/api/client_tickets/{id}` | Owner ou ROLE_ADMIN | |
|
||||
| `POST` | `/api/client_tickets` | ROLE_CLIENT | `submittedBy` auto-set depuis le token JWT. Le Processor valide que `user.client` n'est pas null (empêche un admin de créer un ticket même via la hiérarchie de rôles) |
|
||||
| `PATCH` | `/api/client_tickets/{id}` | ROLE_ADMIN uniquement | Changement de statut + `statusComment` |
|
||||
| `DELETE` | `/api/client_tickets/{id}` | ROLE_ADMIN | Cascade sur les documents liés |
|
||||
|
||||
**Note** : ROLE_USER n'a PAS accès à la collection `/api/client_tickets`. L'accès en lecture aux informations d'un ticket se fait via le champ `task.clientTicket` exposé dans le groupe `task:read`.
|
||||
|
||||
### Notification
|
||||
|
||||
| Méthode | Route | Accès | Notes |
|
||||
|---------|-------|-------|-------|
|
||||
| `GET` | `/api/notifications` | Authentifié | Auto-filtré par l'utilisateur courant. Paginé : 30 par page |
|
||||
| `PATCH` | `/api/notifications/{id}` | Owner | Marquer comme lu |
|
||||
| `POST` | `/api/notifications/mark-all-read` | Authentifié | **Endpoint Symfony custom** (controller dédié, pas une opération API Platform) |
|
||||
| `GET` | `/api/notifications/unread-count` | Authentifié | Retourne le count |
|
||||
|
||||
**Nettoyage** : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours). Pas implémenté dans la première version.
|
||||
|
||||
### TaskDocument
|
||||
|
||||
- Les endpoints existants restent, avec ajout du filtre `clientTicket`
|
||||
- Le Processor accepte `task` OU `clientTicket`
|
||||
- Sécurité : ROLE_ADMIN (tous), ROLE_USER (documents liés à une task), ROLE_CLIENT (documents liés à un ticket dont l'utilisateur est le `submittedBy`)
|
||||
|
||||
## State Providers & Processors
|
||||
|
||||
### `ClientTicketProvider`
|
||||
|
||||
- ROLE_CLIENT : filtre par `submittedBy` = utilisateur courant
|
||||
- ROLE_ADMIN : retourne tous les tickets
|
||||
- Vérifie que l'utilisateur-client a accès au projet du ticket (via `allowedProjects`)
|
||||
|
||||
### `ClientTicketNumberProcessor`
|
||||
|
||||
- Sur `POST` : auto-génère le numéro via `SELECT MAX(number) FROM client_ticket WHERE project_id = :project` + 1, avec contrainte unique `(project_id, number)` et retry en cas de conflit
|
||||
- Valide que `user.client` n'est pas null (empêche la création par un admin même si ROLE_ADMIN hérite de ROLE_CLIENT)
|
||||
- Set `submittedBy` depuis le token JWT courant
|
||||
- Set `status` à `new`
|
||||
- Set `createdAt` et `updatedAt`
|
||||
|
||||
### `ClientTicketStatusProcessor`
|
||||
|
||||
- Sur `PATCH` : valide la transition de statut
|
||||
- Transitions interdites : `done` → `new`, `rejected` → `new`
|
||||
- `statusComment` obligatoire si le nouveau statut est `rejected`
|
||||
- Met à jour `updatedAt`
|
||||
|
||||
### `ClientTicketNotificationProcessor`
|
||||
|
||||
- Sur `POST` (ticket créé) : crée une `Notification` pour tous les utilisateurs ROLE_ADMIN
|
||||
- Type : `ticket_created`
|
||||
- Title : "Nouveau ticket client CT-XXX"
|
||||
- Message : titre du ticket + nom du projet
|
||||
- Sur `PATCH` (changement de statut) : crée une `Notification` pour le `submittedBy`
|
||||
- Type : `ticket_status_changed`
|
||||
- Title : "Ticket CT-XXX mis à jour"
|
||||
- Message : nouveau statut + `statusComment` si présent
|
||||
|
||||
### `NotificationProvider`
|
||||
|
||||
- Toujours filtré par l'utilisateur courant (`user` = token JWT)
|
||||
- Paginé : 30 résultats par page
|
||||
- Endpoint `unread-count` : `SELECT COUNT(*) WHERE user = :user AND isRead = false`
|
||||
|
||||
### `MarkAllReadController`
|
||||
|
||||
Endpoint custom Symfony (`POST /api/notifications/mark-all-read`) :
|
||||
- Récupère l'utilisateur depuis le token JWT
|
||||
- Exécute `UPDATE notification SET is_read = true WHERE user_id = :user AND is_read = false`
|
||||
- Retourne `204 No Content`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Routing & Middleware
|
||||
|
||||
Modification de `auth.global.ts` :
|
||||
- ROLE_CLIENT → redirigé vers `/portal`, accès bloqué à `/projects`, `/admin`, `/time-tracking`, etc.
|
||||
- ROLE_ADMIN / ROLE_USER → peut accéder à `/portal` pour voir la vue côté client
|
||||
|
||||
### Pages du portail
|
||||
|
||||
#### `/portal` — Liste des projets
|
||||
|
||||
- Affiche les projets auxquels l'utilisateur-client a accès (`allowedProjects`)
|
||||
- Cartes simples : nom du projet, nombre de tickets ouverts
|
||||
- Clic → `/portal/projects/{id}`
|
||||
|
||||
#### `/portal/projects/{id}` — Tickets d'un projet
|
||||
|
||||
- Liste des tickets soumis sur ce projet
|
||||
- Pour chaque ticket : numéro (CT-XXX), type badge, titre, statut badge, date de création
|
||||
- Bouton "Nouveau ticket" → `/portal/projects/{id}/new-ticket`
|
||||
- Clic sur un ticket → modale de détail (lecture seule : titre, description, url, statut, statusComment, documents)
|
||||
|
||||
#### `/portal/projects/{id}/new-ticket` — Formulaire de création
|
||||
|
||||
- Select type : `bug`, `improvement`, `other`
|
||||
- Champ title (requis)
|
||||
- Champ description (requis, textarea)
|
||||
- Champ url (affiché uniquement si `type = bug`)
|
||||
- Zone d'upload de documents (réutilise les composants TaskDocument existants)
|
||||
- Bouton soumettre
|
||||
|
||||
### Modifications des pages existantes
|
||||
|
||||
#### Kanban (`/projects/{id}`)
|
||||
|
||||
- Icône `heroicons:user-circle` affichée à côté du titre de la task si `task.clientTicket` est set
|
||||
- Tooltip au survol : "Lié au ticket client CT-XXX" (données disponibles via `task:read`)
|
||||
|
||||
#### `/my-tasks`
|
||||
|
||||
- Même icône et tooltip que le kanban
|
||||
|
||||
#### `/admin` — Nouvel onglet "Tickets client"
|
||||
|
||||
- Liste de tous les tickets, avec filtres par projet et statut
|
||||
- Pour chaque ticket : numéro, type, titre, statut, projet, soumis par, date
|
||||
- Actions :
|
||||
- Changer le statut (select + champ statusComment si rejection)
|
||||
- Voir le détail du ticket (modale avec documents)
|
||||
|
||||
### Services API
|
||||
|
||||
#### `frontend/services/client-tickets.ts`
|
||||
|
||||
```typescript
|
||||
getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]>
|
||||
getById(id: number): Promise<ClientTicket>
|
||||
create(data: { type: string; title: string; description: string; url?: string; project: string }): Promise<ClientTicket>
|
||||
updateStatus(id: number, data: { status: string; statusComment?: string }): Promise<ClientTicket>
|
||||
remove(id: number): Promise<void>
|
||||
```
|
||||
|
||||
#### `frontend/services/notifications.ts`
|
||||
|
||||
```typescript
|
||||
getAll(page?: number): Promise<Notification[]>
|
||||
markAsRead(id: number): Promise<void>
|
||||
markAllAsRead(): Promise<void>
|
||||
getUnreadCount(): Promise<number>
|
||||
```
|
||||
|
||||
### DTOs TypeScript
|
||||
|
||||
#### `frontend/services/dto/client-ticket.ts`
|
||||
|
||||
```typescript
|
||||
type ClientTicketType = 'bug' | 'improvement' | 'other'
|
||||
type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
|
||||
|
||||
type ClientTicket = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
number: number
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url: string | null
|
||||
status: ClientTicketStatus
|
||||
statusComment: string | null
|
||||
project: string // IRI
|
||||
submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: TaskDocument[]
|
||||
}
|
||||
```
|
||||
|
||||
#### `frontend/services/dto/notification.ts`
|
||||
|
||||
```typescript
|
||||
type NotificationType = 'ticket_created' | 'ticket_status_changed'
|
||||
|
||||
type Notification = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: string // IRI
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
relatedTicket: string | null // IRI
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### Composants réutilisés
|
||||
|
||||
- `TaskDocumentUpload` → généralisé avec prop `clientTicketId` comme alternative à `taskId`
|
||||
- `TaskDocumentList` + `TaskDocumentPreview` → réutilisés dans la modale de détail du ticket
|
||||
|
||||
### Composants à créer
|
||||
|
||||
#### `frontend/components/notification/NotificationBell.vue`
|
||||
|
||||
- Placé dans le header de la navbar
|
||||
- Icône cloche avec badge rouge (nombre de notifications non lues)
|
||||
- Clic → dropdown avec les notifications récentes (paginé, 30 par page)
|
||||
- Chaque notification : titre, message (tronqué), date relative, indicateur lu/non-lu
|
||||
- Clic sur une notification → marque comme lue + navigation vers le ticket lié
|
||||
- Bouton "Tout marquer comme lu"
|
||||
|
||||
### Composable `useNotifications()`
|
||||
|
||||
```typescript
|
||||
const useNotifications = () => {
|
||||
const unreadCount: Ref<number>
|
||||
const notifications: Ref<Notification[]>
|
||||
|
||||
const fetchNotifications: (page?: number) => Promise<void>
|
||||
const fetchUnreadCount: () => Promise<void>
|
||||
const markAsRead: (id: number) => Promise<void>
|
||||
const markAllAsRead: () => Promise<void>
|
||||
|
||||
// Polling toutes les 2 minutes
|
||||
const startPolling: () => void
|
||||
const stopPolling: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Le polling démarre au montage de `NotificationBell` et s'arrête au démontage.
|
||||
|
||||
### Clés i18n
|
||||
|
||||
Ajouter dans `frontend/i18n/locales/fr.json` (français uniquement pour le moment) :
|
||||
|
||||
```
|
||||
# Portal
|
||||
portal.title → "Portail client"
|
||||
portal.projects → "Mes projets"
|
||||
portal.openTickets → "tickets ouverts"
|
||||
portal.newTicket → "Nouveau ticket"
|
||||
portal.ticketDetail → "Détail du ticket"
|
||||
|
||||
# Client Ticket
|
||||
clientTicket.type.bug → "Bug"
|
||||
clientTicket.type.improvement → "Amélioration"
|
||||
clientTicket.type.other → "Autre"
|
||||
clientTicket.status.new → "Nouveau"
|
||||
clientTicket.status.in_progress → "En cours"
|
||||
clientTicket.status.done → "Terminé"
|
||||
clientTicket.status.rejected → "Rejeté"
|
||||
clientTicket.title → "Titre"
|
||||
clientTicket.description → "Description"
|
||||
clientTicket.url → "URL (page concernée)"
|
||||
clientTicket.statusComment → "Commentaire de statut"
|
||||
clientTicket.created → "Ticket créé"
|
||||
clientTicket.statusChanged → "Statut mis à jour"
|
||||
clientTicket.confirmDelete → "Supprimer ce ticket ?"
|
||||
clientTicket.linkedTooltip → "Lié au ticket client {number}"
|
||||
clientTicket.rejectionRequired → "Un commentaire est requis pour rejeter un ticket"
|
||||
|
||||
# Notifications
|
||||
notification.title → "Notifications"
|
||||
notification.markAllRead → "Tout marquer comme lu"
|
||||
notification.empty → "Aucune notification"
|
||||
notification.ticketCreated → "Nouveau ticket client {number}"
|
||||
notification.ticketStatusChanged → "Ticket {number} mis à jour"
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Nouvelles tables
|
||||
|
||||
**`client_ticket`** :
|
||||
- Colonnes correspondant à l'entité `ClientTicket`
|
||||
- Index sur `project_id`
|
||||
- Index sur `submitted_by_id`
|
||||
- Index composite sur `(status, project_id)` pour les filtres admin
|
||||
- Contrainte unique sur `(project_id, number)` pour la numérotation par projet
|
||||
- FK `project_id` → `project.id` ON DELETE CASCADE
|
||||
- FK `submitted_by_id` → `user.id` **ON DELETE SET NULL**
|
||||
|
||||
**`notification`** :
|
||||
- Colonnes correspondant à l'entité `Notification`
|
||||
- Index sur `user_id`
|
||||
- Index composite sur `(user_id, is_read)` pour le count non-lu
|
||||
- FK `user_id` → `user.id` ON DELETE CASCADE
|
||||
- FK `related_ticket_id` → `client_ticket.id` ON DELETE SET NULL
|
||||
|
||||
**`user_allowed_projects`** (table de jointure ManyToMany) :
|
||||
- `user_id` → `user.id` ON DELETE CASCADE
|
||||
- `project_id` → `project.id` ON DELETE CASCADE
|
||||
|
||||
### Modifications de tables existantes
|
||||
|
||||
**`user`** :
|
||||
- Ajout colonne `client_id` (nullable) — FK → `client.id` ON DELETE SET NULL
|
||||
|
||||
**`task`** :
|
||||
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE SET NULL
|
||||
|
||||
**`task_document`** (table conservée, pas de renommage) :
|
||||
- Colonne `task_id` devient nullable
|
||||
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE CASCADE
|
||||
- Contrainte CHECK : `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Hiérarchie des rôles
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
security:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||
```
|
||||
|
||||
### Contrôle d'accès
|
||||
|
||||
| Ressource | ROLE_CLIENT | ROLE_USER | ROLE_ADMIN |
|
||||
|-----------|-------------|-----------|------------|
|
||||
| ClientTicket (ses propres) | Lecture + Création | Lecture via `task:read` (champ `task.clientTicket`) | CRUD complet |
|
||||
| ClientTicket collection `/api/client_tickets` | Ses propres tickets | — | Tous |
|
||||
| Notification (ses propres) | Lecture + Mark as read | Lecture + Mark as read | Lecture + Mark as read |
|
||||
| TaskDocument (lié à une task) | — | Lecture | CRUD complet |
|
||||
| TaskDocument (lié à un ticket) | Lecture + Upload (si `submittedBy` = soi) | — | CRUD complet |
|
||||
| Task, Project, Client, TimeEntry, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup | — | Accès normal (`is_granted('ROLE_USER')`) | Accès normal |
|
||||
| Pages /portal | Accès | Accès | Accès |
|
||||
| Pages /projects, /admin | — | Accès | Accès |
|
||||
|
||||
### Validation du Provider ClientTicket
|
||||
|
||||
- ROLE_CLIENT : vérifie que le projet du ticket fait partie de `allowedProjects` de l'utilisateur
|
||||
- ROLE_CLIENT : ne peut voir que les tickets où `submittedBy` = lui-même
|
||||
- ROLE_ADMIN : aucune restriction
|
||||
|
||||
### Validation du Processor ClientTicket (POST)
|
||||
|
||||
- Vérifie que `user.client` n'est pas null — un utilisateur admin ne peut pas créer de ticket même s'il hérite de ROLE_CLIENT via la hiérarchie de rôles
|
||||
|
||||
## Phases de livraison
|
||||
|
||||
### Phase 1 — Fondations
|
||||
|
||||
1. **Prérequis sécurité** : modifier `User::getRoles()` pour ne plus ajouter `ROLE_USER` aux utilisateurs `ROLE_CLIENT` ; ajouter `security: "is_granted('ROLE_USER')"` sur les opérations GetCollection/Get de Task, Project, Client, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry
|
||||
2. Modifier `User` : ajouter `client` (ManyToOne → Client, nullable), `allowedProjects` (ManyToMany → Project), rôle `ROLE_CLIENT`, groupes de sérialisation `me:read`, `user:read`, `user:write`
|
||||
3. Généraliser `TaskDocument` : `task` devient nullable, ajout `clientTicket` (ManyToOne → ClientTicket, nullable), contrainte CHECK, Processor généralisé
|
||||
4. Créer l'entité `ClientTicket` + migration (avec contrainte unique `(project_id, number)`)
|
||||
5. API CRUD `ClientTicket` avec sécurité (Provider, Processor, validation `user.client` sur POST, validation des transitions de statut sur PATCH)
|
||||
6. Admin : gestion des utilisateurs-clients (créer un user avec ROLE_CLIENT, lié à un client + projets autorisés)
|
||||
|
||||
### Phase 2 — Portail client
|
||||
|
||||
1. Pages `/portal`, `/portal/projects/{id}`, formulaire de création de ticket
|
||||
2. Upload de documents sur les tickets (réutilisation des composants TaskDocument existants, généralisés avec prop `clientTicketId`)
|
||||
3. Lien `Task.clientTicket` + icône dans le kanban et `/my-tasks` (données via `task:read`)
|
||||
4. Admin : onglet tickets client (liste, changement de statut)
|
||||
|
||||
### Phase 3 — Notifications
|
||||
|
||||
1. Entité `Notification` + API (paginé, 30 par page)
|
||||
2. `MarkAllReadController` — endpoint Symfony custom (`POST /api/notifications/mark-all-read`)
|
||||
3. Auto-création des notifications dans le `ClientTicketNotificationProcessor`
|
||||
4. `NotificationBell.vue` avec polling toutes les 2 minutes
|
||||
5. Composable `useNotifications()`
|
||||
6. Note : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours)
|
||||
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
|
||||
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal file
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Task Documents — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs peuvent uploader des fichiers via drag & drop ou sélection, les visualiser (images, PDF) dans une modale plein écran, et les télécharger.
|
||||
|
||||
## Contraintes
|
||||
|
||||
- **Taille max par fichier** : 50 Mo
|
||||
- **Types acceptés** : tous types de fichiers
|
||||
- **Nombre par ticket** : illimité
|
||||
- **Stockage** : filesystem local (`var/uploads/documents/`)
|
||||
- **Permissions** : ROLE_ADMIN pour créer/supprimer, ROLE_USER pour lire
|
||||
- **Contexte** : application single-tenant, tous les utilisateurs voient tous les projets — pas de scoping projet
|
||||
|
||||
## Backend
|
||||
|
||||
### Entité `TaskDocument`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `task` | ManyToOne → Task | Ticket parent (CASCADE on delete) |
|
||||
| `originalName` | string (255) | Nom original du fichier uploadé |
|
||||
| `fileName` | string (255) | Nom unique sur disque (`{uuid}.{extension}`) |
|
||||
| `mimeType` | string (100) | Type MIME (ex: `image/png`, `application/pdf`) |
|
||||
| `size` | int | Taille en octets |
|
||||
| `createdAt` | DateTimeImmutable | Date d'upload |
|
||||
| `uploadedBy` | ManyToOne → User | Utilisateur ayant uploadé (SET NULL on delete) |
|
||||
|
||||
### Relation inverse sur Task
|
||||
|
||||
- `Task.documents` : OneToMany → TaskDocument, avec `cascade: ['remove']` côté Doctrine
|
||||
- Sérialisé dans le groupe `task:read` pour charger les documents avec le ticket
|
||||
|
||||
### Nettoyage des fichiers à la suppression
|
||||
|
||||
Quand un `TaskDocument` est supprimé (directement ou par cascade depuis Task), le fichier physique doit aussi être supprimé. Stratégie :
|
||||
|
||||
- **Doctrine EntityListener** (`TaskDocumentListener`) avec événement `preRemove`
|
||||
- Récupère le `fileName` de l'entité et supprime le fichier de `var/uploads/documents/`
|
||||
- Si le fichier n'existe pas sur disque (déjà supprimé manuellement), log un warning et continue sans erreur
|
||||
|
||||
Ceci couvre les deux cas :
|
||||
1. Suppression directe d'un document via `DELETE /api/task_documents/{id}`
|
||||
2. Suppression en cascade quand une Task est supprimée
|
||||
|
||||
### Stockage filesystem
|
||||
|
||||
- Répertoire : `var/uploads/documents/`
|
||||
- Nommage : `{uuid}.{extension}` — évite les collisions et les caractères spéciaux
|
||||
- Volume Docker dédié pour persister les uploads
|
||||
- Ajouter `var/uploads/` dans `.gitignore`
|
||||
|
||||
### Téléchargement des fichiers
|
||||
|
||||
Endpoint dédié Symfony servi via un State Provider :
|
||||
|
||||
| Méthode | Route | Description | Accès |
|
||||
|---------|-------|-------------|-------|
|
||||
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier (BinaryFileResponse) | ROLE_USER |
|
||||
|
||||
- Contrôle d'accès via authentification JWT (pas d'accès anonyme)
|
||||
- Retourne le fichier avec les headers `Content-Disposition` (inline pour images/PDF, attachment pour les autres)
|
||||
- Le frontend n'expose jamais le `fileName` interne dans l'URL — utilise l'`id` du document
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Méthode | Route | Description | Accès |
|
||||
|---------|-------|-------------|-------|
|
||||
| `POST` | `/api/task_documents` | Upload multipart/form-data | ROLE_ADMIN |
|
||||
| `GET` | `/api/task_documents?task=/api/tasks/{id}` | Liste documents d'un ticket | ROLE_USER |
|
||||
| `GET` | `/api/task_documents/{id}` | Métadonnées d'un document | ROLE_USER |
|
||||
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier | ROLE_USER |
|
||||
| `DELETE` | `/api/task_documents/{id}` | Supprime document + fichier | ROLE_ADMIN |
|
||||
|
||||
### State Processor — POST (`TaskDocumentProcessor`)
|
||||
|
||||
1. Reçoit le fichier via multipart/form-data + IRI de la task
|
||||
2. Valide : fichier non vide, taille ≤ 50 Mo
|
||||
3. Génère un UUID v4, extrait l'extension du nom original
|
||||
4. Déplace le fichier uploadé dans `var/uploads/documents/{uuid}.{ext}`
|
||||
5. Si le déplacement du fichier échoue, throw une exception — ne pas persister l'entité
|
||||
6. Crée et persiste l'entité `TaskDocument` avec toutes les métadonnées
|
||||
7. Set `uploadedBy` depuis le token JWT courant
|
||||
|
||||
### State Processor — DELETE
|
||||
|
||||
1. Supprime l'entité de la base de données
|
||||
2. Le nettoyage du fichier est géré automatiquement par le `TaskDocumentListener.preRemove`
|
||||
|
||||
### Validation
|
||||
|
||||
- Contrainte sur `originalName` : NotBlank
|
||||
- Contrainte sur `task` : NotNull
|
||||
- Validation dans le Processor : taille fichier ≤ 50 Mo, fichier présent dans la requête
|
||||
- PHP `upload_max_filesize` et `post_max_size` à configurer ≥ 50 Mo
|
||||
|
||||
### Configuration PHP/Nginx
|
||||
|
||||
- `php.ini` : `upload_max_filesize = 50M`, `post_max_size = 55M`
|
||||
- Nginx : `client_max_body_size 55m;`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Placement dans l'UI
|
||||
|
||||
La zone de documents est placée **sous la description** dans le `TaskModal`, visible en mode édition.
|
||||
|
||||
### Composants à créer
|
||||
|
||||
Tous dans `frontend/components/task/` :
|
||||
|
||||
#### `TaskDocumentUpload.vue`
|
||||
|
||||
- Zone drag & drop avec bordure pointillée
|
||||
- Texte : "Glisser des fichiers ici ou cliquer pour sélectionner" (clé i18n : `taskDocuments.dropzone`)
|
||||
- Input file caché (`multiple`, `accept="*"`)
|
||||
- Événements : `dragover`, `dragleave`, `drop` pour le feedback visuel
|
||||
- Barre de progression par fichier pendant l'upload
|
||||
- Upload **séquentiel** (un POST multipart par fichier, un à la fois) — plus simple et prévisible pour les progress bars
|
||||
- Émet un événement quand l'upload est terminé pour rafraîchir la liste
|
||||
|
||||
#### `TaskDocumentList.vue`
|
||||
|
||||
- Grille de cartes compactes pour chaque document
|
||||
- **Images** (`image/*`) : miniature 64x64 en `object-fit: cover`, chargée depuis l'URL de download
|
||||
- Note : les images sont chargées en pleine résolution pour les miniatures. C'est une limitation acceptée — la génération de thumbnails côté serveur pourra être ajoutée ultérieurement si besoin.
|
||||
- **Autres fichiers** : icône selon le type MIME :
|
||||
- PDF → icône PDF
|
||||
- Word/Excel → icônes Office
|
||||
- Archives → icône archive
|
||||
- Défaut → icône fichier générique
|
||||
- Informations affichées : nom original (tronqué si > ~30 chars), taille formatée (Ko/Mo)
|
||||
- Clic sur un document → ouvre `TaskDocumentPreview`
|
||||
- Bouton supprimer (visible uniquement pour ROLE_ADMIN, avec confirmation)
|
||||
|
||||
#### `TaskDocumentPreview.vue`
|
||||
|
||||
- Modale plein écran (overlay sombre semi-transparent)
|
||||
- Contenu selon le type :
|
||||
- **Images** (`image/*`) : `<img>` centré, taille adaptative
|
||||
- **PDF** (`application/pdf`) : `<iframe>` intégré
|
||||
- **Autres** : grande icône + nom du fichier + taille + bouton "Télécharger"
|
||||
- Navigation : flèches gauche/droite pour parcourir les documents du ticket
|
||||
- Fermeture : bouton X en haut à droite, clic sur l'overlay, touche Escape
|
||||
- Raccourcis clavier : flèches pour naviguer, Escape pour fermer
|
||||
|
||||
### Service API
|
||||
|
||||
`frontend/services/task-documents.ts` :
|
||||
|
||||
```typescript
|
||||
getByTask(taskId: number): Promise<TaskDocument[]>
|
||||
upload(taskId: number, file: File): Promise<TaskDocument>
|
||||
remove(id: number): Promise<void>
|
||||
getDownloadUrl(id: number): string // Retourne `/api/task_documents/{id}/download`
|
||||
```
|
||||
|
||||
**Note upload :** la fonction `upload` ne peut pas utiliser `useApi().post()` directement car celui-ci set `Content-Type: application/json`. L'upload doit utiliser `$fetch` directement avec un `FormData` comme body et ne PAS setter de `Content-Type` (le navigateur le fait automatiquement avec le boundary multipart).
|
||||
|
||||
### DTO TypeScript
|
||||
|
||||
`frontend/services/dto/task-document.ts` :
|
||||
|
||||
```typescript
|
||||
type TaskDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
task: string // IRI
|
||||
originalName: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: string | null // IRI ou null si user supprimé
|
||||
}
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
Ajouter dans `frontend/i18n/locales/` :
|
||||
|
||||
```
|
||||
taskDocuments.dropzone → "Glisser des fichiers ici ou cliquer pour sélectionner"
|
||||
taskDocuments.uploaded → "Document uploadé"
|
||||
taskDocuments.deleted → "Document supprimé"
|
||||
taskDocuments.uploadError → "Erreur lors de l'upload"
|
||||
taskDocuments.confirmDelete → "Supprimer ce document ?"
|
||||
taskDocuments.download → "Télécharger"
|
||||
taskDocuments.documents → "Documents"
|
||||
```
|
||||
|
||||
### Intégration dans TaskModal
|
||||
|
||||
- Import des 3 composants dans `TaskModal.vue`
|
||||
- Sous le champ description :
|
||||
1. `TaskDocumentUpload` (si mode édition, ROLE_ADMIN)
|
||||
2. `TaskDocumentList` (toujours visible, passe les documents du ticket)
|
||||
- `TaskDocumentPreview` monté conditionnellement (v-if sur document sélectionné)
|
||||
- Chargement des documents : via la relation `task.documents` déjà sérialisée, ou appel séparé au service
|
||||
|
||||
## Migration
|
||||
|
||||
- Nouvelle table `task_document` avec les colonnes correspondant à l'entité
|
||||
- Index sur `task_id` pour les requêtes filtrées
|
||||
- Clé étrangère `task_id` → `task.id` ON DELETE CASCADE
|
||||
- Clé étrangère `uploaded_by_id` → `user.id` ON DELETE SET NULL
|
||||
|
||||
## Docker
|
||||
|
||||
- Ajouter un volume nommé dans `docker-compose.yml` pour `var/uploads/` afin de persister les fichiers
|
||||
- Le volume est monté dans le service PHP uniquement (pas besoin dans Nginx car les fichiers sont servis via Symfony)
|
||||
- Vérifier la config PHP pour `upload_max_filesize` et `post_max_size`
|
||||
|
||||
## .gitignore
|
||||
|
||||
Ajouter `var/uploads/` dans `.gitignore` pour éviter de committer des fichiers uploadés en dev local.
|
||||
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
|
||||
@@ -1,96 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un effort
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="2" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun effort trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskEffortDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
const { getAll, remove } = useTaskEffortService()
|
||||
const items = ref<TaskEffort[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskEffort | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskEffort) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter une priorité
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucune priorité trouvée.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskPriorityDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
const { getAll, remove } = useTaskPriorityService()
|
||||
const items = ref<TaskPriority[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskPriority | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskPriority) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un statut
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Position</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-700">{{ item.position }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="4" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun statut trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const { getAll, remove } = useTaskStatusService()
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Types</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun type trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TaskTypeDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskType } from '~/services/dto/task-type'
|
||||
import { useTaskTypeService } from '~/services/task-types'
|
||||
|
||||
const { getAll, remove } = useTaskTypeService()
|
||||
const items = ref<TaskType[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskType | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskType) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Nom d'utilisateur</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Rôles</th>
|
||||
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-for="role in item.roles"
|
||||
:key="role"
|
||||
class="mr-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-700"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||
Aucun utilisateur trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<UserDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { getAll, remove } = useUserService()
|
||||
const items = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<UserData | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: UserData) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||
<div class="flex h-full items-center justify-end">
|
||||
<div class="flex gap-12 text-xl text-white">
|
||||
<div class="group relative flex gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
||||
<div class="invisible absolute right-0 top-full z-20 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
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
>
|
||||
Mon profil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
defineProps<{
|
||||
user?: UserData
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project | null
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.project) {
|
||||
form.name = props.project.name ?? ''
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
} else {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
}
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProjectWrite = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<button
|
||||
class="shrink-0 text-neutral-400 hover:text-primary-500"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:play-circle-outline" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="type in task.types"
|
||||
:key="type.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: type.color }"
|
||||
>
|
||||
{{ type.label }}
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
:title="task.assignee.username"
|
||||
>
|
||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent) {
|
||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
group: TaskGroup | null
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.group)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.group) {
|
||||
form.title = props.group.title ?? ''
|
||||
form.description = props.group.description ?? ''
|
||||
form.color = props.group.color ?? '#222783'
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskGroupService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskGroupWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.group) {
|
||||
await update(props.group.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
114
frontend/components/admin/AdminBookStackTab.vue
Normal file
114
frontend/components/admin/AdminBookStackTab.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('bookstack.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('bookstack.settings.url')"
|
||||
:placeholder="$t('bookstack.settings.urlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('bookstack.settings.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('bookstack.settings.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult ? $t('bookstack.settings.testSuccess') : $t('bookstack.settings.testFailed') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
const form = reactive({
|
||||
url: '',
|
||||
tokenId: '',
|
||||
tokenSecret: '',
|
||||
})
|
||||
|
||||
const hasToken = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.url = settings.url ?? ''
|
||||
hasToken.value = settings.hasToken
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
url: form.url.trim() || null,
|
||||
tokenId: form.tokenId || null,
|
||||
tokenSecret: form.tokenSecret || null,
|
||||
})
|
||||
hasToken.value = result.hasToken
|
||||
form.tokenId = ''
|
||||
form.tokenSecret = ''
|
||||
testResult.value = null
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const result = await testConnection()
|
||||
testResult.value = result.success
|
||||
} catch {
|
||||
testResult.value = false
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
97
frontend/components/admin/AdminClientTab.vue
Normal file
97
frontend/components/admin/AdminClientTab.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="clients"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun client trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-email="{ item }">
|
||||
{{ item.email ?? '-' }}
|
||||
</template>
|
||||
<template #cell-address="{ item }">
|
||||
{{ formatAddress(item) }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ item.phone ?? '-' }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ClientDrawer
|
||||
v-model="drawerOpen"
|
||||
:client="selectedClient"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'name', label: 'Nom', primary: true },
|
||||
{ key: 'email', label: 'Email', class: 'text-primary-500' },
|
||||
{ key: 'address', label: 'Adresse', class: 'text-neutral-700' },
|
||||
{ key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useClientService()
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
|
||||
async function loadClients() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
clients.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedClient.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(client: Client) {
|
||||
selectedClient.value = client
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function formatAddress(client: Client): string {
|
||||
return [client.street, client.postalCode, client.city]
|
||||
.filter(Boolean)
|
||||
.join(', ') || '-'
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
})
|
||||
</script>
|
||||
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>
|
||||
78
frontend/components/admin/AdminEffortTab.vue
Normal file
78
frontend/components/admin/AdminEffortTab.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un effort
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun effort trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
/>
|
||||
|
||||
<TaskEffortDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useTaskEffortService()
|
||||
const items = ref<TaskEffort[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskEffort | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskEffort) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
103
frontend/components/admin/AdminGiteaTab.vue
Normal file
103
frontend/components/admin/AdminGiteaTab.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('gitea.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('gitea.settings.url')"
|
||||
:placeholder="$t('gitea.settings.urlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('gitea.settings.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('gitea.settings.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult ? $t('gitea.settings.testSuccess') : $t('gitea.settings.testFailed') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
const form = reactive({
|
||||
url: '',
|
||||
token: '',
|
||||
})
|
||||
|
||||
const hasToken = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.url = settings.url ?? ''
|
||||
hasToken.value = settings.hasToken
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
url: form.url.trim() || null,
|
||||
token: form.token || null,
|
||||
})
|
||||
hasToken.value = result.hasToken
|
||||
form.token = ''
|
||||
testResult.value = null
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const result = await testConnection()
|
||||
testResult.value = result.success
|
||||
} catch {
|
||||
testResult.value = false
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
86
frontend/components/admin/AdminPriorityTab.vue
Normal file
86
frontend/components/admin/AdminPriorityTab.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter une priorité
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucune priorité trouvée."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskPriorityDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useTaskPriorityService()
|
||||
const items = ref<TaskPriority[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskPriority | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskPriority) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
139
frontend/components/admin/AdminStatusTab.vue
Normal file
139
frontend/components/admin/AdminStatusTab.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un statut
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun statut trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="requestDelete"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteStatusModal
|
||||
v-model="confirmModalOpen"
|
||||
:status-label="statusToDelete?.label ?? ''"
|
||||
:task-count="affectedTaskCount"
|
||||
:available-statuses="reassignTargets"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
||||
]
|
||||
|
||||
const statusService = useTaskStatusService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | null>(null)
|
||||
const confirmModalOpen = ref(false)
|
||||
const statusToDelete = ref<TaskStatus | null>(null)
|
||||
|
||||
const affectedTaskCount = computed(() => {
|
||||
if (!statusToDelete.value) return 0
|
||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
||||
})
|
||||
|
||||
const reassignTargets = computed(() => {
|
||||
if (!statusToDelete.value) return items.value
|
||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
||||
})
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [statuses, allTasks] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
taskService.getAll(),
|
||||
])
|
||||
items.value = statuses
|
||||
tasks.value = allTasks
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function requestDelete(item: TaskStatus) {
|
||||
statusToDelete.value = item
|
||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
||||
if (count === 0) {
|
||||
await statusService.remove(item.id)
|
||||
await loadItems()
|
||||
} else {
|
||||
confirmModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmDelete(targetStatusId: number | null) {
|
||||
if (!statusToDelete.value) return
|
||||
|
||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
||||
|
||||
await Promise.all(
|
||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
||||
)
|
||||
|
||||
await statusService.remove(statusToDelete.value.id)
|
||||
confirmModalOpen.value = false
|
||||
statusToDelete.value = null
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
86
frontend/components/admin/AdminTagTab.vue
Normal file
86
frontend/components/admin/AdminTagTab.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun tag trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskTagDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useTaskTagService()
|
||||
const items = ref<TaskTag[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskTag | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskTag) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
89
frontend/components/admin/AdminUserTab.vue
Normal file
89
frontend/components/admin/AdminUserTab.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun utilisateur trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-roles="{ item }">
|
||||
<span
|
||||
v-for="role in item.roles"
|
||||
:key="role"
|
||||
class="mr-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-700"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<UserDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'username', label: "Nom d'utilisateur", primary: true },
|
||||
{ key: 'roles', label: 'Rôles' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useUserService()
|
||||
const items = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<UserData | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: UserData) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
354
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
354
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<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"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
/>
|
||||
</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>
|
||||
<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">
|
||||
<MalioInputText
|
||||
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>
|
||||
271
frontend/components/project/ProjectDrawer.vue
Normal file
271
frontend/components/project/ProjectDrawer.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
label="Code"
|
||||
input-class="w-full uppercase"
|
||||
:disabled="isEditing"
|
||||
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
|
||||
@blur="touched.code = true"
|
||||
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div v-if="giteaRepos.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.giteaRepoFullName"
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="bookstackShelves.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.bookstackShelfId"
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="project.taskCount === 0"
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project | null
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
const giteaRepoOptions = computed(() =>
|
||||
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
|
||||
)
|
||||
|
||||
const { listShelves } = useBookStackService()
|
||||
const bookstackShelves = ref<BookStackShelf[]>([])
|
||||
|
||||
const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
name: false,
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.project) {
|
||||
form.code = props.project.code ?? ''
|
||||
form.name = props.project.name ?? ''
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||
: null
|
||||
form.bookstackShelfId = props.project.bookstackShelfId ?? null
|
||||
} else {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
touched.code = true
|
||||
if (!form.name.trim()) return
|
||||
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProjectWrite = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||
}
|
||||
|
||||
if (form.giteaRepoFullName) {
|
||||
const [owner, repo] = form.giteaRepoFullName.split('/')
|
||||
payload.giteaOwner = owner
|
||||
payload.giteaRepo = repo
|
||||
} else {
|
||||
payload.giteaOwner = null
|
||||
payload.giteaRepo = null
|
||||
}
|
||||
|
||||
if (form.bookstackShelfId) {
|
||||
const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId)
|
||||
payload.bookstackShelfId = form.bookstackShelfId
|
||||
payload.bookstackShelfName = shelf?.name ?? null
|
||||
} else {
|
||||
payload.bookstackShelfId = null
|
||||
payload.bookstackShelfName = null
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code.trim()
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.project.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
confirmDeleteOpen.value = false
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const newArchived = !props.project.archived
|
||||
await update(props.project.id, { archived: newArchived }, {
|
||||
toastSuccessKey: newArchived ? 'projects.archived' : 'projects.unarchived',
|
||||
})
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
// Gitea not configured, ignore
|
||||
}
|
||||
try {
|
||||
bookstackShelves.value = await listShelves()
|
||||
} catch {
|
||||
// BookStack not configured, ignore
|
||||
}
|
||||
})
|
||||
</script>
|
||||
170
frontend/components/project/ProjectGroupTab.vue
Normal file
170
frontend/components/project/ProjectGroupTab.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!showArchived"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un groupe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun groupe trouvé."
|
||||
:deletable="!showArchived"
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ item.description ?? '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<button
|
||||
v-if="!showArchived && canArchiveGroup(item)"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
@click.stop="handleArchive(item)"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showArchived"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
@click.stop="handleUnarchive(item)"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskGroupDrawer
|
||||
v-model="drawerOpen"
|
||||
:group="selectedItem"
|
||||
:project-id="projectId"
|
||||
:tasks="[...activeTasks, ...archivedTasks]"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'title', label: 'Titre', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'description', label: 'Description', class: 'max-w-xs truncate text-neutral-700' },
|
||||
]
|
||||
|
||||
const groupService = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const allGroups = ref<TaskGroup[]>([])
|
||||
const activeTasks = ref<Task[]>([])
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskGroup | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
const items = computed(() =>
|
||||
allGroups.value.filter(g => showArchived.value ? g.archived : !g.archived)
|
||||
)
|
||||
|
||||
function canArchiveGroup(group: TaskGroup): boolean {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
if (groupTasks.length === 0) return false
|
||||
return groupTasks.every(t => t.status?.isFinal === true)
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [g, t, at] = await Promise.all([
|
||||
groupService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId, true),
|
||||
])
|
||||
allGroups.value = g
|
||||
activeTasks.value = t
|
||||
archivedTasks.value = at
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskGroup) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await groupService.remove(id)
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleArchive(group: TaskGroup) {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: true })))
|
||||
await groupService.update(group.id, { archived: true })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleUnarchive(group: TaskGroup) {
|
||||
const groupTasks = archivedTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: false })))
|
||||
await groupService.update(group.id, { archived: false })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
158
frontend/components/task/TaskBookStackLinks.vue
Normal file
158
frontend/components/task/TaskBookStackLinks.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<MalioInputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('bookstack.links.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Dropdown results -->
|
||||
<div
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="handleAdd(result)"
|
||||
>
|
||||
<Icon
|
||||
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<span class="truncate">{{ result.name }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Linked documents -->
|
||||
<div v-if="links.length > 0" class="mt-3 space-y-1">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
|
||||
>
|
||||
<Icon
|
||||
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<a
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate text-primary-500 hover:underline"
|
||||
>
|
||||
{{ link.title }}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
@click="handleRemove(link.id)"
|
||||
>
|
||||
<Icon name="mdi:close" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const { getLinks, addLink, removeLink, search } = useBookStackService()
|
||||
|
||||
const links = ref<BookStackLink[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<BookStackSearchResult[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchQuery, (query) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
hasSearched.value = false
|
||||
searchResults.value = []
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isSearching.value = true
|
||||
try {
|
||||
searchResults.value = await search(props.taskId, query.trim())
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
hasSearched.value = true
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function handleAdd(result: BookStackSearchResult) {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
hasSearched.value = false
|
||||
|
||||
// Check if already linked
|
||||
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await addLink(props.taskId, {
|
||||
bookstackId: result.id,
|
||||
bookstackType: result.type,
|
||||
title: result.name,
|
||||
url: result.url,
|
||||
})
|
||||
links.value.unshift(created)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(linkId: number) {
|
||||
try {
|
||||
await removeLink(props.taskId, linkId)
|
||||
links.value = links.value.filter(l => l.id !== linkId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
links.value = await getLinks(props.taskId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
131
frontend/components/task/TaskBulkActions.vue
Normal file
131
frontend/components/task/TaskBulkActions.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click="emit('bulk-delete')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
</script>
|
||||
112
frontend/components/task/TaskCard.vue
Normal file
112
frontend/components/task/TaskCard.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
|
||||
function onPlay() {
|
||||
timerStore.startFromTask(props.task)
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent) {
|
||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||
}
|
||||
</script>
|
||||
77
frontend/components/task/TaskDocumentList.vue
Normal file
77
frontend/components/task/TaskDocumentList.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="mt-3">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">
|
||||
{{ $t('taskDocuments.title') }} ({{ documents.length }})
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
<!-- Thumbnail or icon -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
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"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
defineProps<{
|
||||
documents: TaskDocument[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
preview: [doc: TaskDocument]
|
||||
delete: [doc: TaskDocument]
|
||||
}>()
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getIconForMime(mimeType: string): string {
|
||||
if (mimeType === 'application/pdf') return 'heroicons:document-text'
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
|
||||
return 'heroicons:paper-clip'
|
||||
}
|
||||
|
||||
</script>
|
||||
119
frontend/components/task/TaskDocumentPreview.vue
Normal file
119
frontend/components/task/TaskDocumentPreview.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" appear>
|
||||
<div
|
||||
v-if="document"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
|
||||
@click.self="$emit('close')"
|
||||
@keydown.escape="$emit('close')"
|
||||
@keydown.left="$emit('prev')"
|
||||
@keydown.right="$emit('next')"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<button
|
||||
v-if="hasPrev"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasNext"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||
<!-- Image preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="downloadUrl"
|
||||
:alt="document.originalName"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="downloadUrl"
|
||||
class="h-[85vh] w-[80vw] rounded-lg bg-white"
|
||||
/>
|
||||
|
||||
<!-- Generic file -->
|
||||
<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" />
|
||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- File name footer -->
|
||||
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
document: TaskDocument | null
|
||||
hasPrev: boolean
|
||||
hasNext: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
|
||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||
|
||||
// Focus overlay for keyboard events
|
||||
watch(() => props.document, (doc) => {
|
||||
if (doc) {
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
138
frontend/components/task/TaskDocumentUpload.vue
Normal file
138
frontend/components/task/TaskDocumentUpload.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
|
||||
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
|
||||
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
|
||||
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
|
||||
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="[
|
||||
upload.error ? 'bg-red-500' : upload.uploading ? 'animate-pulse bg-blue-400' : 'bg-green-500',
|
||||
]"
|
||||
:style="{ width: upload.uploading ? '70%' : `${upload.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="upload.error"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="h-5 w-5 shrink-0 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
clientTicketId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
type UploadState = {
|
||||
name: string
|
||||
progress: number
|
||||
uploading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const uploads = ref<UploadState[]>([])
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.length) {
|
||||
processFiles(Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > maxSize) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.maxSizeError'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const state: UploadState = reactive({
|
||||
name: file.name,
|
||||
progress: 30,
|
||||
uploading: true,
|
||||
error: false,
|
||||
})
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.clientTicketId) {
|
||||
await uploadForTicket(props.clientTicketId, file)
|
||||
} else if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
state.progress = 100
|
||||
} catch {
|
||||
state.uploading = false
|
||||
state.error = true
|
||||
state.progress = 100
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.uploadError'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('uploaded')
|
||||
}
|
||||
|
||||
// Clean up completed uploads after a delay
|
||||
setTimeout(() => {
|
||||
uploads.value = uploads.value.filter(u => u.error)
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -50,39 +50,73 @@
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Types</p>
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="type in types"
|
||||
:key="type.id"
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.typeIds.includes(type.id)
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="type.id"
|
||||
:checked="form.typeIds.includes(type.id)"
|
||||
@change="toggleType(type.id)"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ type.label }}
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Enregistrer
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
@@ -91,7 +125,7 @@ import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskType } from '~/services/dto/task-type'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
@@ -103,7 +137,7 @@ const props = defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
efforts: TaskEffort[]
|
||||
priorities: TaskPriority[]
|
||||
types: TaskType[]
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
}>()
|
||||
@@ -120,6 +154,7 @@ const isOpen = computed({
|
||||
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
@@ -129,7 +164,7 @@ const form = reactive({
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
typeIds: [] as number[],
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -156,41 +191,108 @@ const groupOptions = computed(() =>
|
||||
props.groups.map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
function toggleType(id: number) {
|
||||
const idx = form.typeIds.indexOf(id)
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
const status = props.statuses.find(s => s.id === props.task?.status?.id)
|
||||
return !!status?.isFinal
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.task?.archived
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.typeIds.splice(idx, 1)
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.typeIds.push(id)
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
form.description = task.description ?? ''
|
||||
form.statusId = task.status?.id ?? null
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.statusId = null
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.task) {
|
||||
form.title = props.task.title ?? ''
|
||||
form.description = props.task.description ?? ''
|
||||
form.statusId = props.task.status?.id ?? null
|
||||
form.effortId = props.task.effort?.id ?? null
|
||||
form.priorityId = props.task.priority?.id ?? null
|
||||
form.assigneeId = props.task.assignee?.id ?? null
|
||||
form.groupId = props.task.group?.id ?? null
|
||||
form.typeIds = props.task.types.map(t => t.id)
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.statusId = null
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.typeIds = []
|
||||
}
|
||||
touched.title = false
|
||||
populateForm(props.task)
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskService()
|
||||
watch(() => props.task, (task) => {
|
||||
if (props.modelValue) {
|
||||
populateForm(task)
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.task.id)
|
||||
confirmDeleteOpen.value = false
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.task) return
|
||||
const timerStore = useTimerStore()
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
@@ -207,7 +309,7 @@ async function handleSubmit() {
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
types: form.typeIds.map(id => `/api/task_types/${id}`),
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.task) {
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
425
frontend/components/task/TaskGitSection.vue
Normal file
425
frontend/components/task/TaskGitSection.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
|
||||
<!-- Header with tabs -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'branches'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'branches'"
|
||||
>
|
||||
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.title') }}
|
||||
<span
|
||||
v-if="branches.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
|
||||
>{{ branches.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'prs'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'prs'"
|
||||
>
|
||||
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.pr.title') }}
|
||||
<span
|
||||
v-if="pullRequests.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
|
||||
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
|
||||
>{{ pullRequests.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
|
||||
:title="$t('gitea.branch.copy')"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
>
|
||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
|
||||
:disabled="isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
||||
</button>
|
||||
</div>
|
||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||
{{ branchPreview }}
|
||||
</code>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Content area with scroll -->
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
||||
<!-- Loading -->
|
||||
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
|
||||
</div>
|
||||
|
||||
<!-- BRANCHES TAB -->
|
||||
<template v-if="activeTab === 'branches' && !isLoading">
|
||||
<div v-if="branches.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="branch in branches"
|
||||
:key="branch.name"
|
||||
class="group"
|
||||
>
|
||||
<!-- Branch header (clickable to expand) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
|
||||
@click="toggleBranch(branch.name)"
|
||||
>
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="14"
|
||||
class="shrink-0 text-neutral-400 transition-transform"
|
||||
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
|
||||
/>
|
||||
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
|
||||
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
|
||||
{{ branch.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="branch.commits.length"
|
||||
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
||||
>
|
||||
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<a
|
||||
:href="branchUrl(branch.name)"
|
||||
target="_blank"
|
||||
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:open-in-new" size="12" />
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<!-- Commits (collapsible) -->
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
|
||||
<div
|
||||
v-for="(commit, idx) in branch.commits.slice(0, 10)"
|
||||
:key="commit.sha"
|
||||
class="flex items-center gap-2 px-4 py-1.5"
|
||||
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
|
||||
>
|
||||
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
|
||||
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
|
||||
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="branch.commits.length > 10"
|
||||
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
|
||||
>
|
||||
+{{ branch.commits.length - 10 }} commits
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.branch.noBranches') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- PULL REQUESTS TAB -->
|
||||
<template v-if="activeTab === 'prs' && !isLoadingPrs">
|
||||
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="pr in pullRequests"
|
||||
:key="pr.number"
|
||||
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
|
||||
>
|
||||
<!-- Status pill -->
|
||||
<span
|
||||
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
|
||||
:class="prStatusClass(pr)"
|
||||
>
|
||||
{{ prStatusLabel(pr) }}
|
||||
</span>
|
||||
|
||||
<!-- PR content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
:href="pr.url"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
|
||||
>
|
||||
<span class="text-neutral-400">#{{ pr.number }}</span>
|
||||
{{ pr.title }}
|
||||
</a>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
|
||||
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
|
||||
{{ pr.headBranch }}
|
||||
</span>
|
||||
<!-- CI statuses -->
|
||||
<template v-if="pr.ciStatuses.length">
|
||||
<a
|
||||
v-for="ci in pr.ciStatuses"
|
||||
:key="ci.context"
|
||||
:href="ci.target_url"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
|
||||
:class="ciStatusClass(ci.status)"
|
||||
>
|
||||
<Icon :name="ciStatusIcon(ci.status)" size="10" />
|
||||
{{ ci.context }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.pr.noPrs') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
giteaUrl: string
|
||||
}>()
|
||||
|
||||
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
|
||||
|
||||
const activeTab = ref<'branches' | 'prs'>('branches')
|
||||
const branches = ref<GiteaBranch[]>([])
|
||||
const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
const branchForm = reactive({
|
||||
type: 'feature',
|
||||
baseBranch: 'develop',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('gitea.branch.types.feature'), value: 'feature' },
|
||||
{ label: t('gitea.branch.types.fix'), value: 'fix' },
|
||||
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
|
||||
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
|
||||
{ label: t('gitea.branch.types.chore'), value: 'chore' },
|
||||
]
|
||||
|
||||
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
|
||||
|
||||
const branchPreview = computed(() => {
|
||||
if (!props.task.project?.code || !props.task.number) return ''
|
||||
const slug = props.task.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50)
|
||||
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
|
||||
})
|
||||
|
||||
function toggleBranch(name: string) {
|
||||
if (expandedBranches.value.has(name)) {
|
||||
expandedBranches.value.delete(name)
|
||||
} else {
|
||||
expandedBranches.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function branchUrl(name: string): string {
|
||||
const project = props.task.project
|
||||
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
|
||||
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function commitFirstLine(message: string): string {
|
||||
return message.split('\n')[0]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) return "aujourd'hui"
|
||||
if (diffDays === 1) return 'hier'
|
||||
if (diffDays < 7) return `il y a ${diffDays}j`
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function prStatusClass(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return 'bg-purple-500'
|
||||
if (pr.state === 'open') return 'bg-green-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function prStatusLabel(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return t('gitea.pr.merged')
|
||||
if (pr.state === 'open') return t('gitea.pr.open')
|
||||
return t('gitea.pr.closed')
|
||||
}
|
||||
|
||||
function ciStatusClass(status: string): string {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700'
|
||||
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
}
|
||||
|
||||
function ciStatusIcon(status: string): string {
|
||||
if (status === 'success') return 'mdi:check-circle'
|
||||
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
|
||||
return 'mdi:clock-outline'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.task.id) return
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
// Auto-expand first branch
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
pullRequests.value = await listPullRequests(props.task.id)
|
||||
} catch {
|
||||
// PR errors don't block branch display
|
||||
} finally {
|
||||
isLoadingPrs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
isCreating.value = true
|
||||
try {
|
||||
await createBranch(props.task.id, {
|
||||
type: branchForm.type,
|
||||
baseBranch: branchForm.baseBranch,
|
||||
})
|
||||
showCreateForm.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const result = await getBranchName(props.task.id, branchForm.type)
|
||||
await navigator.clipboard.writeText(result.name)
|
||||
const { success } = useToast()
|
||||
success(t('gitea.branch.copied'))
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-down-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
181
frontend/components/task/TaskGroupDrawer.vue
Normal file
181
frontend/components/task/TaskGroupDrawer.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditing && !canArchive && !canUnarchive && nonFinalTasksCount > 0"
|
||||
class="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('archive.groupNonFinalTasks', { count: nonFinalTasksCount }) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
group: TaskGroup | null
|
||||
projectId: number
|
||||
tasks?: Task[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.group)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.group) {
|
||||
form.title = props.group.title ?? ''
|
||||
form.description = props.group.description ?? ''
|
||||
form.color = props.group.color ?? '#222783'
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const groupTasks = computed(() =>
|
||||
(props.tasks ?? []).filter(t => t.group?.id === props.group?.id)
|
||||
)
|
||||
|
||||
const nonFinalTasksCount = computed(() =>
|
||||
groupTasks.value.filter(t => t.status?.isFinal !== true).length
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.group || props.group.archived) return false
|
||||
if (groupTasks.value.length === 0) return false
|
||||
return nonFinalTasksCount.value === 0
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.group?.archived
|
||||
})
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: true })))
|
||||
await update(props.group.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: false })))
|
||||
await update(props.group.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskGroupWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.group) {
|
||||
await update(props.group.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
108
frontend/components/task/TaskListItem.vue
Normal file
108
frontend/components/task/TaskListItem.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
663
frontend/components/task/TaskModal.vue
Normal file
663
frontend/components/task/TaskModal.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="task-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="isEditing && task?.project?.code && task?.number"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||
</h2>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Client ticket link -->
|
||||
<div
|
||||
v-if="isEditing && task?.clientTicket"
|
||||
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
||||
<span class="text-sm font-medium text-blue-700">
|
||||
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="ticketStatusClass(task.clientTicket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@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 -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<MalioSelect
|
||||
v-model="form.statusId"
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="clientTicketOptions.length"
|
||||
v-model="form.clientTicketId"
|
||||
:options="clientTicketOptions"
|
||||
label="Ticket client"
|
||||
empty-option-label="Aucun ticket client"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="tags.length" class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-5">
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentUpload
|
||||
v-if="isEditing && task && isAdmin"
|
||||
:task-id="task.id"
|
||||
@uploaded="handleDocumentUploaded"
|
||||
/>
|
||||
<TaskDocumentList
|
||||
v-if="isEditing && task"
|
||||
:documents="localDocuments"
|
||||
:is-admin="isAdmin"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
/>
|
||||
|
||||
<!-- Document preview modal -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Git section -->
|
||||
<TaskGitSection
|
||||
v-if="hasGitea && isEditing && task"
|
||||
:task="task"
|
||||
:gitea-url="giteaUrl"
|
||||
/>
|
||||
|
||||
<!-- BookStack links -->
|
||||
<TaskBookStackLinks
|
||||
v-if="hasBookStack && isEditing && task"
|
||||
:task-id="task.id"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<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="close"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Confirm delete document modal -->
|
||||
<ConfirmDeleteDocumentModal
|
||||
v-model="confirmDeleteDocOpen"
|
||||
@confirm="confirmDeleteDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
task: Task | null
|
||||
projectId: number
|
||||
statuses: TaskStatus[]
|
||||
efforts: TaskEffort[]
|
||||
priorities: TaskPriority[]
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
projects?: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
|
||||
const hasGitea = computed(() => {
|
||||
return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value
|
||||
})
|
||||
|
||||
const hasBookStack = computed(() => {
|
||||
return !!props.task?.project?.bookstackShelfId
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
statusId: null as number | null,
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
project: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
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(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
const status = props.statuses.find(s => s.id === props.task?.status?.id)
|
||||
return !!status?.isFinal
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.task?.archived
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
form.description = task.description ?? ''
|
||||
form.statusId = task.status?.id ?? null
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.statusId = null
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
}
|
||||
touched.title = false
|
||||
touched.project = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.task, (task) => {
|
||||
if (props.modelValue) {
|
||||
populateForm(task)
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const clientTickets = ref<ClientTicket[]>([])
|
||||
const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// 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 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 previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
// Sync documents from task prop when modal opens or task changes
|
||||
watch(() => props.task?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.task) return
|
||||
localDocuments.value = await getDocumentsByTask(props.task.id)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteDocOpen = ref(false)
|
||||
const documentToDelete = ref<TaskDocument | null>(null)
|
||||
|
||||
function handleDeleteDocument(doc: TaskDocument) {
|
||||
documentToDelete.value = doc
|
||||
confirmDeleteDocOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteDocument() {
|
||||
if (!documentToDelete.value) return
|
||||
await removeDocument(documentToDelete.value.id)
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function handleDocumentUploaded() {
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.task.id)
|
||||
confirmDeleteOpen.value = false
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.task) return
|
||||
const timerStore = useTimerStore()
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
touched.project = true
|
||||
if (!form.title.trim()) return
|
||||
if (showProjectSelect.value && !form.projectId) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-modal-enter-active,
|
||||
.task-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.task-modal-enter-active > div:last-child,
|
||||
.task-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.task-modal-enter-from,
|
||||
.task-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -18,6 +18,18 @@
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
id="isFinal"
|
||||
v-model="form.isFinal"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="isFinal" class="text-sm font-medium text-neutral-700">
|
||||
{{ $t('archive.statusFinal') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -57,6 +69,7 @@ const form = reactive({
|
||||
label: '',
|
||||
position: '0',
|
||||
color: '#222783',
|
||||
isFinal: false,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -69,10 +82,12 @@ watch(() => props.modelValue, (open) => {
|
||||
form.label = props.item.label ?? ''
|
||||
form.position = String(props.item.position ?? 0)
|
||||
form.color = props.item.color ?? '#222783'
|
||||
form.isFinal = props.item.isFinal ?? false
|
||||
} else {
|
||||
form.label = ''
|
||||
form.position = '0'
|
||||
form.color = '#222783'
|
||||
form.isFinal = false
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
@@ -90,6 +105,7 @@ async function handleSubmit() {
|
||||
label: form.label.trim(),
|
||||
position: Number(form.position),
|
||||
color: form.color,
|
||||
isFinal: form.isFinal,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un type' : 'Ajouter un type'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -26,12 +26,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskType, TaskTypeWrite } from '~/services/dto/task-type'
|
||||
import { useTaskTypeService } from '~/services/task-types'
|
||||
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskType | null
|
||||
item: TaskTag | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -69,7 +69,7 @@ watch(() => props.modelValue, (open) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskTypeService()
|
||||
const { create, update } = useTaskTagService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
@@ -77,7 +77,7 @@ async function handleSubmit() {
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskTypeWrite = {
|
||||
const payload: TaskTagWrite = {
|
||||
label: form.label.trim(),
|
||||
color: form.color,
|
||||
}
|
||||
269
frontend/components/time-tracking/TimeEntryBlock.vue
Normal file
269
frontend/components/time-tracking/TimeEntryBlock.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div
|
||||
ref="blockEl"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||
:style="blockStyle"
|
||||
:class="{ 'opacity-40': isDragSource }"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@mousedown="onMouseDown"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Resize handle top (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
||||
style="bottom: 100%"
|
||||
@mousedown.stop.prevent="onResizeTopStart"
|
||||
>
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
||||
<!-- Top: title + project -->
|
||||
<div class="min-w-0">
|
||||
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom: tags left, duration right -->
|
||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle bottom (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
||||
style="top: 100%"
|
||||
@mousedown.stop.prevent="onResizeBottomStart"
|
||||
>
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: TimeEntry
|
||||
hourHeight: number
|
||||
dayStartHour: number
|
||||
isDragSource?: boolean
|
||||
columnIndex?: number
|
||||
totalColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', entry: TimeEntry): void
|
||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
||||
}>()
|
||||
|
||||
const blockEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const startDate = computed(() => new Date(props.entry.startedAt))
|
||||
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
||||
|
||||
const resizeTopDeltaMinutes = ref(0)
|
||||
const resizeBottomDeltaMinutes = ref(0)
|
||||
|
||||
const duration = computed(() => {
|
||||
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
||||
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
const heightPx = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
||||
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
||||
})
|
||||
|
||||
// Responsive content levels based on block height
|
||||
// 3 = full (title + project + types + duration)
|
||||
// 2 = medium (title + duration)
|
||||
// 1 = small (title only)
|
||||
// 0 = tiny (colored bar only)
|
||||
const sizeLevel = computed(() => {
|
||||
const h = heightPx.value
|
||||
if (h >= 50) return 3
|
||||
if (h >= 35) return 2
|
||||
if (h >= 20) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||
|
||||
const col = props.columnIndex ?? 0
|
||||
const total = props.totalColumns ?? 1
|
||||
const gapPx = 2
|
||||
const leftPercent = (col / total) * 100
|
||||
const widthPercent = (1 / total) * 100
|
||||
|
||||
const base: Record<string, string> = {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx.value}px`,
|
||||
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||
}
|
||||
|
||||
if (hasProject.value) {
|
||||
const hex = props.entry.project!.color.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
||||
base.color = `rgb(${r}, ${g}, ${b})`
|
||||
} else {
|
||||
base.backgroundColor = '#e5e7eb'
|
||||
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
||||
base.backgroundSize = '12px 12px'
|
||||
base.color = '#6b7280'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Click / Drag detection ---
|
||||
let mouseDownPos = { x: 0, y: 0 }
|
||||
let mouseDownHandled = false
|
||||
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.button !== 0) return
|
||||
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
||||
|
||||
mouseDownPos = { x: event.clientX, y: event.clientY }
|
||||
mouseDownHandled = false
|
||||
|
||||
document.addEventListener('mousemove', onMouseMoveDetect)
|
||||
document.addEventListener('mouseup', onMouseUpDetect)
|
||||
}
|
||||
|
||||
function onMouseMoveDetect(event: MouseEvent) {
|
||||
const dx = event.clientX - mouseDownPos.x
|
||||
const dy = event.clientY - mouseDownPos.y
|
||||
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
||||
mouseDownHandled = true
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
|
||||
const rect = blockEl.value!.getBoundingClientRect()
|
||||
emit('moveStart', {
|
||||
entry: props.entry,
|
||||
offsetY: mouseDownPos.y - rect.top,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUpDetect() {
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
if (!mouseDownHandled) {
|
||||
emit('click', props.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resize bottom (change stoppedAt) ---
|
||||
function onResizeBottomStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 's-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeBottomDeltaMinutes.value
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// --- Resize top (change startedAt) ---
|
||||
function onResizeTopStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'n-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeTopDeltaMinutes.value
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
</script>
|
||||
89
frontend/components/time-tracking/TimeEntryContextMenu.vue
Normal file
89
frontend/components/time-tracking/TimeEntryContextMenu.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuEl"
|
||||
class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
|
||||
:style="{ top: `${y}px`, left: `${x}px` }"
|
||||
>
|
||||
<button
|
||||
v-if="entry"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
@click="onCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="16" />
|
||||
Copier
|
||||
</button>
|
||||
<button
|
||||
v-if="canPaste"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
@click="onPaste"
|
||||
>
|
||||
<Icon name="mdi:content-paste" size="16" />
|
||||
Coller
|
||||
</button>
|
||||
<button
|
||||
v-if="entry"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
@click="onDelete"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="16" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
entry?: TimeEntry | null
|
||||
canPaste: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'copy', entry: TimeEntry): void
|
||||
(e: 'paste'): void
|
||||
(e: 'delete', entry: TimeEntry): void
|
||||
}>()
|
||||
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onCopy() {
|
||||
if (props.entry) emit('copy', props.entry)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onPaste() {
|
||||
emit('paste')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (props.entry) emit('delete', props.entry)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
setTimeout(() => document.addEventListener('click', onClickOutside), 0)
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
296
frontend/components/time-tracking/TimeEntryDrawer.vue
Normal file
296
frontend/components/time-tracking/TimeEntryDrawer.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Que fais-tu ?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Début</label>
|
||||
<input
|
||||
v-model="form.startTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Fin</label>
|
||||
<input
|
||||
v-model="form.endTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="durationLabel"
|
||||
class="rounded-md bg-neutral-100 px-3 py-2 text-center text-sm font-semibold text-neutral-600 tabular-nums"
|
||||
>
|
||||
{{ durationLabel }}
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
min-width="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.projectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
entry?: TimeEntry | null
|
||||
prefillStartedAt?: string | null
|
||||
users: UserData[]
|
||||
projects: Project[]
|
||||
tags: TaskTag[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.entry)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
userId: authStore.user?.id ?? null as number | null,
|
||||
projectId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
props.projects.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (!form.startTime || !form.endTime) return ''
|
||||
const [sh, sm] = form.startTime.split(':').map(Number) as [number, number]
|
||||
const [eh, em] = form.endTime.split(':').map(Number) as [number, number]
|
||||
const diff = (eh * 60 + em) - (sh * 60 + sm)
|
||||
if (diff <= 0) return ''
|
||||
const h = Math.floor(diff / 60)
|
||||
const m = diff % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function toLocalTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(11, 16)
|
||||
}
|
||||
|
||||
function toISO(date: string, time: string): string {
|
||||
return new Date(`${date}T${time}`).toISOString()
|
||||
}
|
||||
|
||||
function populateForm(entry: TimeEntry | null | undefined) {
|
||||
if (entry) {
|
||||
form.title = entry.title ?? ''
|
||||
form.description = entry.description ?? ''
|
||||
form.date = toLocalDate(entry.startedAt)
|
||||
form.startTime = toLocalTime(entry.startedAt)
|
||||
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
|
||||
form.userId = entry.user?.id ?? authStore.user?.id ?? null
|
||||
form.projectId = entry.project?.id ?? null
|
||||
form.tagIds = entry.tags?.map(t => t.id) ?? []
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.date = props.prefillStartedAt ? toLocalDate(props.prefillStartedAt) : new Date().toISOString().slice(0, 10)
|
||||
form.startTime = props.prefillStartedAt ? toLocalTime(props.prefillStartedAt) : ''
|
||||
form.endTime = ''
|
||||
form.userId = authStore.user?.id ?? null
|
||||
form.projectId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
if (open) {
|
||||
populateForm(entry)
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
await remove(props.entry.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create, update } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.entry) {
|
||||
await update(props.entry.id, payload)
|
||||
} else {
|
||||
await create(payload as TimeEntryWrite)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
104
frontend/components/time-tracking/TimeEntryList.vue
Normal file
104
frontend/components/time-tracking/TimeEntryList.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<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">
|
||||
{{ $t('timeEntries.noEntries') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
@click="emit('editEntry', entry)"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
<div
|
||||
class="h-10 w-1 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
|
||||
<!-- Main info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time info -->
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-sm font-semibold tabular-nums text-neutral-900">
|
||||
{{ formatDuration(entry) }}
|
||||
</div>
|
||||
<div class="text-xs tabular-nums text-neutral-400">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="hidden shrink-0 text-xs text-neutral-400 sm:block">
|
||||
{{ formatDate(entry.startedAt) }}
|
||||
</div>
|
||||
|
||||
<!-- Delete action -->
|
||||
<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"
|
||||
:title="$t('common.delete')"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'editEntry', entry: TimeEntry): void
|
||||
(e: 'deleteEntry', entry: TimeEntry): void
|
||||
}>()
|
||||
|
||||
const sortedEntries = computed(() => {
|
||||
return [...props.entries].sort((a, b) => {
|
||||
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
function formatDuration(entry: TimeEntry): string {
|
||||
const start = new Date(entry.startedAt).getTime()
|
||||
const end = entry.stoppedAt ? new Date(entry.stoppedAt).getTime() : Date.now()
|
||||
const diff = end - start
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const m = Math.floor((diff % 3600000) / 60000)
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user