Compare commits
164 Commits
c0b16ef6dc
...
v0.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df0b15fe7 | ||
|
|
8040245e45 | ||
|
|
5d378c1f75 | ||
|
|
8544babf8c | ||
|
|
455121132d | ||
|
|
fd3097cc26 | ||
|
|
ff7cff1d39 | ||
|
|
ed58a402b0 | ||
|
|
2ac815d074 | ||
|
|
e0dfcbdbf8 | ||
|
|
5db6b1e2b0 | ||
|
|
6e29aeb30f | ||
|
|
cca548dfbc | ||
|
|
3d4b7fad12 | ||
|
|
5ffb4bbedc | ||
|
|
d2e9f9ed65 | ||
|
|
c5898fbf74 | ||
|
|
0180dd3715 | ||
|
|
0f99098291 | ||
|
|
1c6f473dff | ||
|
|
c95fff530c | ||
|
|
fb0e6c1ea4 | ||
|
|
6d3ecc1322 | ||
|
|
f5986090c0 | ||
|
|
d6399c20e1 | ||
|
|
a972d243f5 | ||
|
|
56bf88f293 | ||
| 9d80e017c2 | |||
| 4e91507158 | |||
| 318f14ea88 | |||
| 202b516dc3 | |||
| 98782a9849 | |||
| b978adf9ae | |||
| e4fc34b90f | |||
| a5144443a4 | |||
| afd4baed92 | |||
| e8f0202b15 | |||
| 962b3d935c | |||
| cea22f977b | |||
| 5613a7c92b | |||
| 4d0aa65920 | |||
| 63315c0a15 | |||
| cff16611f4 | |||
| 96f5c7c91c | |||
| f7a76c9e9b | |||
| 7047f64a6b | |||
| cd8cea45c1 | |||
| 1f31a3a33f | |||
| 254f8bc411 | |||
| 239cd6398e | |||
| 318b6198da | |||
| 4e3e854aa2 | |||
| 49cd971e3e | |||
| ffe4a0117c | |||
| d2f6d84d03 | |||
| 2a874046d3 | |||
| f09ef67117 | |||
| 046ee396d3 | |||
| 0ba487cfa9 | |||
| a2fc8e6e52 | |||
| 6c910e7fcc | |||
| 6d7e6f5f48 | |||
| 0c8fb654a9 | |||
| f8748c4061 | |||
| 2c28a4ad1d | |||
| cf1cf1ff5c | |||
| 0724d38a26 | |||
| 17c5160f2c | |||
| 40d6f7693f | |||
| e63ed63dd8 | |||
| ad8142ac9d | |||
| f7afe1c6fb | |||
| 697075eea2 | |||
| 587733e6f9 | |||
| 59b11f1225 | |||
| 4094048aba | |||
| ce2eaa03e1 | |||
| d932359024 | |||
| 669c36cea1 | |||
| 3d1a510d82 | |||
| 68dd9599a9 | |||
| 0d21e59023 | |||
| 7210a0d96f | |||
| 7099f1ca95 | |||
| e16fd2053e | |||
| 760f5b6ad6 | |||
| adf050505d | |||
| 12d043a50f | |||
| bfd418851e | |||
| 4fbbead3e3 | |||
| 64961631e4 | |||
| 7f2371e522 | |||
| 851953df1e | |||
| b6cfe9d7d4 | |||
| f33f2f95ec | |||
| 9a9416d6c8 | |||
| f27297517c | |||
| d2e27a04ce | |||
| 10cde5e2f9 | |||
| 926d6d54c5 | |||
| a538bb3601 | |||
| 97dcff8542 | |||
| 87ab281099 | |||
| 2b9095b1a2 | |||
| 05e24db6ca | |||
| 63febbea45 | |||
| edc441f363 | |||
| f4eec2e6e9 | |||
| 5547c67b30 | |||
| 9e19adc09a | |||
| 8d24949186 | |||
| c2fa308f1e | |||
|
|
4216f1b5a1 | ||
| 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 |
6
.env
6
.env
@@ -1,5 +1,5 @@
|
||||
APP_ENV=dev
|
||||
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
APP_DEBUG=1
|
||||
|
||||
DEFAULT_URI=http://localhost/
|
||||
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
|
||||
###> 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=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
GITEA_ENCRYPTION_KEY=
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
99
.env.example
Normal file
99
.env.example
Normal file
@@ -0,0 +1,99 @@
|
||||
###############################################################################
|
||||
# Lesstime — Fichier d'environnement de reference
|
||||
#
|
||||
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
|
||||
# Les valeurs par defaut dans .env suffisent pour le developpement ;
|
||||
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
|
||||
# etre definis dans .env.local.
|
||||
#
|
||||
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
|
||||
###############################################################################
|
||||
|
||||
# ===========================================================================
|
||||
# App
|
||||
# ===========================================================================
|
||||
|
||||
# Environnement Symfony : dev, test, prod
|
||||
APP_ENV=dev
|
||||
|
||||
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
|
||||
# Active/desactive le mode debug (1 = oui, 0 = non)
|
||||
APP_DEBUG=1
|
||||
|
||||
# URI par defaut de l'application (utilise pour les liens absolus)
|
||||
DEFAULT_URI=http://localhost/
|
||||
|
||||
# ===========================================================================
|
||||
# CORS (nelmio/cors-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Origines autorisees pour les requetes cross-origin (regex)
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
# ===========================================================================
|
||||
# JWT (lexik/jwt-authentication-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Chemin vers la cle privee RSA pour signer les tokens JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
|
||||
# Chemin vers la cle publique RSA pour verifier les tokens JWT
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
|
||||
# Passphrase de la cle privee JWT — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
|
||||
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
|
||||
JWT_COOKIE_SECURE=0
|
||||
|
||||
# Duree de vie du token JWT en secondes (86400 = 24h)
|
||||
JWT_TOKEN_TTL=86400
|
||||
|
||||
# Duree de vie du cookie JWT en secondes (86400 = 24h)
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# ===========================================================================
|
||||
# Base de donnees (Doctrine / PostgreSQL)
|
||||
# ===========================================================================
|
||||
|
||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||
# et injectees automatiquement par Docker Compose.
|
||||
# DATABASE_URL est construite a partir de ces variables.
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
# ===========================================================================
|
||||
# Chiffrement
|
||||
# ===========================================================================
|
||||
|
||||
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
|
||||
# ===========================================================================
|
||||
# Docker (docker/.env.docker)
|
||||
#
|
||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# surcharger localement.
|
||||
# ===========================================================================
|
||||
|
||||
# DOCKER_APP_NAME=lesstime
|
||||
# DOCKER_PHP_VERSION=8.4.6
|
||||
# DOCKER_NODE_VERSION=24.12.0
|
||||
# APP_USER=www-data
|
||||
# POSTGRES_DB=lesstime
|
||||
# POSTGRES_USER=root
|
||||
# POSTGRES_PASSWORD=root
|
||||
# POSTGRES_PORT=5435
|
||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
|
||||
# ===========================================================================
|
||||
# Frontend (frontend/.env)
|
||||
# ===========================================================================
|
||||
|
||||
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
|
||||
# NUXT_PUBLIC_API_BASE=/api
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
|
||||
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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
44
CLAUDE.md
44
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, TaskTag, TaskGroup, TimeEntry)
|
||||
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, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor)
|
||||
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, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, ClientDrawer, ProjectDrawer, ProjectGroupTab, TaskCard, TaskDrawer, TaskModal, TaskEffortDrawer, TaskGroupDrawer, TaskPriorityDrawer, TaskStatusDrawer, TaskTagDrawer, Admin*Tab, SidebarLink, SidebarTimer, TimeEntryBlock, TimeEntryContextMenu, TimeEntryDrawer, TimeEntryList, TimeTrackingCalendar, UserDrawer, ConfirmDeleteStatusModal, ConfirmDeleteTaskModal)
|
||||
frontend/composables/# Composables (useApi, useAppVersion)
|
||||
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, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -69,6 +75,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
||||
- 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
|
||||
|
||||
@@ -78,9 +91,23 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- 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/`)
|
||||
@@ -96,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.*",
|
||||
@@ -23,12 +24,15 @@
|
||||
"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.*"
|
||||
},
|
||||
@@ -87,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"
|
||||
}
|
||||
}
|
||||
|
||||
1993
composer.lock
generated
1993
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,13 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use 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.2.6'
|
||||
|
||||
50
deploy/nginx/lesstime.conf
Normal file
50
deploy/nginx/lesstime.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
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 |
|
||||
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
|
||||
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>
|
||||
381
frontend/components/admin/AdminClientTicketTab.vue
Normal file
381
frontend/components/admin/AdminClientTicketTab.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('clientTicket.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket list -->
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
|
||||
<th class="px-3 py-3">#</th>
|
||||
<th class="px-3 py-3">Type</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
|
||||
<th class="px-3 py-3">Statut</th>
|
||||
<th class="px-3 py-3">Projet</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
|
||||
<th class="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
v-if="getSubmitterUser(ticket.submittedBy)"
|
||||
:user="getSubmitterUser(ticket.submittedBy)!"
|
||||
size="sm"
|
||||
/>
|
||||
{{ getSubmitterName(ticket.submittedBy) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
@click.stop="openDeleteConfirm(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm modal -->
|
||||
<Teleport v-if="deleteModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="deleteModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="deleteModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Ticket detail modal (read-only) -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="detailTicket"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
let result = tickets.value
|
||||
if (filterProjectId.value) {
|
||||
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(t => t.status === filterStatus.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Status change modal
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
// Delete modal
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTarget = ref<ClientTicket | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Detail modal
|
||||
const detailOpen = ref(false)
|
||||
const detailTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterName(iri: string | null): string {
|
||||
if (!iri) return '-'
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return users.value.find(u => u.id === id)?.username ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||
if (!iri) return undefined
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return undefined
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
detailTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
function openDeleteConfirm(ticket: ClientTicket) {
|
||||
deleteTarget.value = ticket
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await clientTicketService.remove(deleteTarget.value.id)
|
||||
deleteModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
|
||||
clientTicketService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
tickets.value = ticketsResult
|
||||
projects.value = projectsResult
|
||||
users.value = usersResult
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-modal-enter-active,
|
||||
.status-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.status-modal-enter-from,
|
||||
.status-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
353
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
353
frontend/components/client-ticket/ClientTicketDetailModal.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ticket-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-if="ticket"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
CT-{{ String(ticket.number).padStart(3, '0') }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ $t('portal.ticketDetail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||
<button
|
||||
v-if="canEdit && !isEditing"
|
||||
type="button"
|
||||
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
|
||||
@click="startEdit"
|
||||
>
|
||||
<Icon name="mdi:pencil-outline" size="16" />
|
||||
{{ $t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-if="isEditing">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="5"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.type === 'bug'" class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.url"
|
||||
type="url"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- View mode -->
|
||||
<template v-else>
|
||||
<!-- Title -->
|
||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
<div v-if="ticket.url" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status comment -->
|
||||
<div v-if="ticket.statusComment" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentList
|
||||
v-if="localDocuments.length"
|
||||
:documents="localDocuments"
|
||||
:is-admin="canEdit"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
/>
|
||||
|
||||
<!-- Document preview -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<TaskDocumentUpload
|
||||
v-if="ticket"
|
||||
:client-ticket-id="ticket.id"
|
||||
@uploaded="refreshDocuments"
|
||||
/>
|
||||
|
||||
<!-- Date -->
|
||||
<p class="mt-6 text-xs text-neutral-400">
|
||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
ticket: ClientTicket | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
isEditing.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
// Edit mode
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (!props.ticket) return false
|
||||
if (isAdmin.value) return true
|
||||
const status = props.ticket.status
|
||||
if (status === 'done' || status === 'rejected') return false
|
||||
const userId = auth.user?.id
|
||||
if (!userId) return false
|
||||
const sub = props.ticket.submittedBy
|
||||
if (!sub) return false
|
||||
// submittedBy can be an IRI string or an embedded object
|
||||
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
|
||||
return false
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.ticket) return
|
||||
editForm.title = props.ticket.title
|
||||
editForm.description = props.ticket.description
|
||||
editForm.url = props.ticket.url ?? ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!props.ticket) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
title: editForm.title,
|
||||
description: editForm.description,
|
||||
}
|
||||
if (props.ticket.type === 'bug') {
|
||||
data.url = editForm.url || null
|
||||
}
|
||||
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
|
||||
isEditing.value = false
|
||||
emit('refresh')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset edit mode when ticket changes
|
||||
watch(() => props.ticket?.id, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
|
||||
async function handleDeleteDocument(doc: TaskDocument) {
|
||||
await removeDocument(doc.id)
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.ticket) return
|
||||
localDocuments.value = await getByTicket(props.ticket.id)
|
||||
}
|
||||
|
||||
// Document list (local copy to allow refresh)
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
|
||||
watch(() => props.ticket?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
// Document preview
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
previewDoc.value = doc
|
||||
}
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-modal-enter-active,
|
||||
.ticket-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-active > div:last-child,
|
||||
.ticket-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from,
|
||||
.ticket-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
327
frontend/components/client-ticket/ProjectClientTickets.vue
Normal file
327
frontend/components/client-ticket/ProjectClientTickets.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
|
||||
@click="open"
|
||||
>
|
||||
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
|
||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||
<span
|
||||
v-if="totalCount > 0"
|
||||
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Panel -->
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ct-panel" appear>
|
||||
<div class="fixed inset-0 z-50 flex justify-end">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Slide panel -->
|
||||
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<!-- Ticket row -->
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="18"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="ct-modal" appear>
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
projectName: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const totalCount = computed(() =>
|
||||
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
async function loadTickets() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
expandedId.value = null
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ct-panel-enter-active,
|
||||
.ct-panel-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ct-panel-enter-active > div:last-child,
|
||||
.ct-panel-leave-active > div:last-child {
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ct-panel-enter-from,
|
||||
.ct-panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ct-panel-enter-from > div:last-child,
|
||||
.ct-panel-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.ct-modal-enter-active,
|
||||
.ct-modal-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ct-modal-enter-from,
|
||||
.ct-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
@@ -43,6 +43,16 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -53,6 +63,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<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>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
@@ -60,8 +81,10 @@
|
||||
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
|
||||
@@ -89,6 +112,13 @@ 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: '',
|
||||
@@ -96,6 +126,7 @@ const form = reactive({
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -118,6 +149,7 @@ watch(() => props.modelValue, (open) => {
|
||||
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 = ''
|
||||
@@ -125,6 +157,7 @@ watch(() => props.modelValue, (open) => {
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
@@ -157,6 +190,15 @@ async function handleSubmit() {
|
||||
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 {
|
||||
@@ -171,11 +213,31 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -117,7 +117,7 @@ async function loadItems() {
|
||||
const [g, t, at] = await Promise.all([
|
||||
groupService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId),
|
||||
taskService.getByProjectArchived(props.projectId),
|
||||
taskService.getByProject(props.projectId, true),
|
||||
])
|
||||
allGroups.value = g
|
||||
activeTasks.value = t
|
||||
|
||||
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>
|
||||
@@ -8,7 +8,15 @@
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<button
|
||||
@@ -36,13 +44,12 @@
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
<UserAvatar
|
||||
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>
|
||||
: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"
|
||||
|
||||
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"
|
||||
@@ -267,7 +267,7 @@ async function handleArchive() {
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ $t('gitea.error') }}</p>
|
||||
<p class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
@@ -248,7 +248,7 @@ const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
@@ -338,7 +338,7 @@ async function loadData() {
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = false
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
@@ -346,8 +346,8 @@ async function loadData() {
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch {
|
||||
error.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-8 py-5">
|
||||
<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
|
||||
@@ -24,7 +24,7 @@
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
|
||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -35,10 +35,27 @@
|
||||
<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-8 py-6">
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -48,8 +65,22 @@
|
||||
@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-2 gap-x-6 gap-y-4">
|
||||
<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"
|
||||
@@ -85,6 +116,14 @@
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="clientTicketOptions.length"
|
||||
v-model="form.clientTicketId"
|
||||
:options="clientTicketOptions"
|
||||
label="Ticket client"
|
||||
empty-option-label="Aucun ticket client"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
@@ -121,6 +160,30 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -128,6 +191,12 @@
|
||||
: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"
|
||||
@@ -183,6 +252,12 @@
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Confirm delete document modal -->
|
||||
<ConfirmDeleteDocumentModal
|
||||
v-model="confirmDeleteDocOpen"
|
||||
@confirm="confirmDeleteDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -191,7 +266,12 @@
|
||||
|
||||
<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'
|
||||
@@ -200,6 +280,8 @@ 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
|
||||
@@ -210,6 +292,7 @@ const props = defineProps<{
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
projects?: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -223,6 +306,7 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
@@ -237,6 +321,10 @@ 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: '',
|
||||
@@ -246,10 +334,13 @@ const form = reactive({
|
||||
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(() =>
|
||||
@@ -268,8 +359,22 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.map(g => ({ label: g.title, value: g.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(() => {
|
||||
@@ -302,6 +407,7 @@ function populateForm(task: Task | null) {
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
@@ -311,13 +417,36 @@ function populateForm(task: Task | null) {
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
}
|
||||
touched.title = false
|
||||
touched.project = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -327,18 +456,98 @@ watch(() => props.task, (task) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
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 {
|
||||
const settings = await getGiteaSettings()
|
||||
giteaUrl.value = settings.url ?? ''
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
// Gitea not available
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
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
|
||||
@@ -359,7 +568,7 @@ async function handleArchive() {
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
@@ -388,7 +597,9 @@ async function handleUnarchive() {
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
touched.project = true
|
||||
if (!form.title.trim()) return
|
||||
if (showProjectSelect.value && !form.projectId) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -400,8 +611,9 @@ async function handleSubmit() {
|
||||
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/${props.projectId}`,
|
||||
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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Full display: title + project + type dot + duration -->
|
||||
<template v-if="sizeLevel >= 3">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
||||
@@ -39,13 +39,13 @@
|
||||
|
||||
<!-- Medium: title + duration -->
|
||||
<template v-else-if="sizeLevel === 2">
|
||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Small: title only -->
|
||||
<template v-else-if="sizeLevel === 1">
|
||||
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Tiny: just a colored bar, no text -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -117,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
@@ -257,7 +257,7 @@ async function onSubmit() {
|
||||
if (isEditing.value && props.entry) {
|
||||
await update(props.entry.id, payload)
|
||||
} else {
|
||||
await create(payload as any)
|
||||
await create(payload as TimeEntryWrite)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
Aucune activité pour cette période
|
||||
{{ $t('timeEntries.noEntries') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || 'Sans titre' }}
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- 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="Supprimer"
|
||||
:title="$t('common.delete')"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative rounded-lg border border-neutral-200 bg-white">
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
class="sticky z-20 flex border-b border-neutral-200 bg-white"
|
||||
:style="{ top: `${stickyOffset}px` }"
|
||||
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg"
|
||||
>
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
@@ -22,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Grid body -->
|
||||
<div ref="gridBodyEl" class="relative flex">
|
||||
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Hour labels -->
|
||||
<div class="w-16 shrink-0">
|
||||
<div
|
||||
@@ -100,7 +99,7 @@
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div class="text-[10px] text-neutral-500">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
@@ -142,6 +141,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
startDate: Date
|
||||
@@ -460,7 +461,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
|
||||
dragState.value = {
|
||||
entryId: entry.id,
|
||||
entry,
|
||||
title: entry.title || 'Sans titre',
|
||||
title: entry.title || t('common.untitled'),
|
||||
color: entry.project?.color ?? '#94a3b8',
|
||||
durationMinutes,
|
||||
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<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>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<button
|
||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
||||
@click="ui.openMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
<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">
|
||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
@click="navigateTo('/profile')"
|
||||
>
|
||||
Mon profil
|
||||
{{ $t('profile.title') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -34,8 +43,9 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
|
||||
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<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"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,19 +4,18 @@
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
||||
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
|
||||
Choisissez où les déplacer :
|
||||
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="targetStatusId"
|
||||
:options="targetOptions"
|
||||
label="Déplacer vers"
|
||||
empty-option-label="Backlog (sans statut)"
|
||||
:label="$t('taskStatuses.moveTo')"
|
||||
:empty-option-label="$t('taskStatuses.backlog')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -27,7 +26,7 @@
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
@click="cancel"
|
||||
>
|
||||
Annuler
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -35,7 +34,7 @@
|
||||
:disabled="isProcessing"
|
||||
@click="confirm"
|
||||
>
|
||||
Supprimer
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<slot name="actions" :item="item" />
|
||||
<button
|
||||
v-if="deletable"
|
||||
class="text-[red-500] hover:text-[red-700]"
|
||||
class="text-neutral-400 transition-colors hover:text-red-500"
|
||||
@click.stop="$emit('delete', item)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
|
||||
216
frontend/components/ui/DateFilter.vue
Normal file
216
frontend/components/ui/DateFilter.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="date-filter">
|
||||
<VueDatePicker
|
||||
ref="datepicker"
|
||||
v-model="internalValue"
|
||||
:week-picker="mode === 'week'"
|
||||
:enable-time-picker="false"
|
||||
:locale="frLocale"
|
||||
:format="formatDisplay"
|
||||
auto-apply
|
||||
:multi-calendars="false"
|
||||
position="left"
|
||||
teleport
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
|
||||
<button
|
||||
class="px-2 py-[7px] text-xs font-medium transition"
|
||||
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||
@click.stop="switchMode('day')"
|
||||
>
|
||||
{{ t('common.day') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-[7px] text-xs font-medium transition"
|
||||
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||
@click.stop="switchMode('week')"
|
||||
>
|
||||
{{ t('common.weekShort') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<input
|
||||
:value="displayValue"
|
||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||
:placeholder="t('common.dateFilter')"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
v-if="internalValue"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="mdi:calendar"
|
||||
size="16"
|
||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action-buttons>
|
||||
<div class="flex gap-2 px-3 pb-2">
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectToday"
|
||||
>
|
||||
{{ t('common.today') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectThisWeek"
|
||||
>
|
||||
{{ t('common.thisWeek') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VueDatePicker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VueDatePicker } from '@vuepic/vue-datepicker'
|
||||
import '@vuepic/vue-datepicker/dist/main.css'
|
||||
import { fr as frLocale } from 'date-fns/locale'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Date | [Date, Date] | null
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Date | [Date, Date] | null]
|
||||
}>()
|
||||
|
||||
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
|
||||
const mode = ref<'day' | 'week'>('week')
|
||||
const internalValue = ref<Date | Date[] | null>(null)
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!internalValue.value) return ''
|
||||
if (internalValue.value instanceof Date) {
|
||||
return formatFullDate(internalValue.value)
|
||||
}
|
||||
if (Array.isArray(internalValue.value) && internalValue.value.length >= 2) {
|
||||
const [start, end] = internalValue.value
|
||||
if (!start || !end) return ''
|
||||
return `${formatShortDate(start)} - ${formatShortDate(end)}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function formatDisplay(dates: Date | Date[]): string {
|
||||
if (!dates) return ''
|
||||
if (dates instanceof Date) return formatFullDate(dates)
|
||||
if (!Array.isArray(dates)) return ''
|
||||
const valid = dates.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||
if (valid.length === 0) return ''
|
||||
if (valid.length === 1) return formatFullDate(valid[0])
|
||||
return `${formatShortDate(valid[0])} - ${formatShortDate(valid[1])}`
|
||||
}
|
||||
|
||||
function formatFullDate(d: Date): string {
|
||||
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const year = d.getFullYear()
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function formatShortDate(d: Date): string {
|
||||
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}/${month}`
|
||||
}
|
||||
|
||||
function switchMode(newMode: 'day' | 'week') {
|
||||
if (mode.value === newMode) return
|
||||
mode.value = newMode
|
||||
internalValue.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function onUpdate(value: Date | Date[] | null) {
|
||||
if (!value) {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode.value === 'week' && Array.isArray(value)) {
|
||||
const valid = value.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||
if (valid.length >= 2) {
|
||||
emit('update:modelValue', [valid[0], valid[1]])
|
||||
}
|
||||
} else if (mode.value === 'day' && value instanceof Date) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
internalValue.value = null
|
||||
datepicker.value?.closeMenu()
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
mode.value = 'day'
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
internalValue.value = today
|
||||
emit('update:modelValue', today)
|
||||
}
|
||||
|
||||
function selectThisWeek() {
|
||||
mode.value = 'week'
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
sunday.setHours(23, 59, 59, 999)
|
||||
internalValue.value = [monday, sunday]
|
||||
emit('update:modelValue', [monday, sunday])
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val === null || val === undefined) {
|
||||
internalValue.value = null
|
||||
} else if (Array.isArray(val)) {
|
||||
internalValue.value = [...val]
|
||||
} else {
|
||||
internalValue.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.date-filter .dp__theme_light {
|
||||
--dp-primary-color: #222783;
|
||||
--dp-primary-text-color: #fff;
|
||||
--dp-border-color: #d4d4d8;
|
||||
--dp-menu-border-color: #d4d4d8;
|
||||
--dp-border-color-hover: #222783;
|
||||
--dp-hover-color: #f3f4f8;
|
||||
--dp-font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-filter .dp__input_wrap {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-filter .dp__main {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white transition"
|
||||
:class="timerStore.isRunning
|
||||
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||
: 'bg-primary-500 hover:bg-primary-600'"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
|
||||
:class="[
|
||||
timerStore.isRunning
|
||||
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||
: 'bg-primary-500 hover:bg-primary-600',
|
||||
collapsed ? 'px-2' : 'px-4'
|
||||
]"
|
||||
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||
>
|
||||
<Icon
|
||||
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
|
||||
size="16"
|
||||
:size="collapsed ? '20' : '16'"
|
||||
/>
|
||||
<span v-if="!collapsed" class="font-mono tracking-wide">
|
||||
{{ timerStore.elapsedFormatted }}
|
||||
|
||||
88
frontend/components/user/AvatarCropper.vue
Normal file
88
frontend/components/user/AvatarCropper.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="mb-4 text-lg font-bold text-neutral-900">
|
||||
{{ $t('profile.cropAvatar') }}
|
||||
</h3>
|
||||
|
||||
<div class="mx-auto mb-4 h-72 w-72">
|
||||
<Cropper
|
||||
ref="cropperRef"
|
||||
:src="imageSrc"
|
||||
:stencil-component="CircleStencil"
|
||||
:stencil-props="{ aspectRatio: 1 }"
|
||||
:canvas="{ width: 256, height: 256 }"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
:disabled="cropping"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
const props = defineProps<{
|
||||
imageFile: File
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'crop', blob: Blob): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const cropperRef = ref()
|
||||
const cropping = ref(false)
|
||||
const imageSrc = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
imageSrc.value = URL.createObjectURL(props.imageFile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageSrc.value) {
|
||||
URL.revokeObjectURL(imageSrc.value)
|
||||
}
|
||||
})
|
||||
|
||||
async function onConfirm() {
|
||||
cropping.value = true
|
||||
|
||||
try {
|
||||
const { canvas } = cropperRef.value.getResult()
|
||||
|
||||
if (!canvas) return
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
})
|
||||
|
||||
if (blob) {
|
||||
emit('crop', blob)
|
||||
}
|
||||
} finally {
|
||||
cropping.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
55
frontend/components/user/UserAvatar.vue
Normal file
55
frontend/components/user/UserAvatar.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||
:class="sizeClasses"
|
||||
:title="user.username"
|
||||
>
|
||||
<img
|
||||
v-if="user.avatarUrl && !imgError"
|
||||
:src="user.avatarUrl"
|
||||
:alt="user.username"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
@error="imgError = true"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
|
||||
:class="textSizeClass"
|
||||
>
|
||||
{{ user.username.substring(0, 2).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
user: { id?: number; username: string; avatarUrl?: string | null }
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}>()
|
||||
|
||||
const imgError = ref(false)
|
||||
|
||||
watch(() => props.user.avatarUrl, () => {
|
||||
imgError.value = false
|
||||
})
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const map = {
|
||||
xs: 'h-5 w-5',
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
return map[props.size ?? 'sm']
|
||||
})
|
||||
|
||||
const textSizeClass = computed(() => {
|
||||
const map = {
|
||||
xs: 'text-[10px]',
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
}
|
||||
return map[props.size ?? 'sm']
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -36,6 +36,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
placeholder="Aucun client"
|
||||
class="w-full"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.clientId !== null" class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowedProjectIds"
|
||||
type="checkbox"
|
||||
:value="project.id"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
{{ project.name }}
|
||||
</label>
|
||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
||||
Aucun projet pour ce client.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -52,6 +85,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -68,15 +107,32 @@ const isOpen = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: t('common.noClient'), value: null as number | null },
|
||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
||||
])
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (form.clientId === null) return []
|
||||
return allProjects.value.filter(
|
||||
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||
)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -84,19 +140,38 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
function onClientChange(value: number | null) {
|
||||
form.clientId = value
|
||||
form.allowedProjectIds = []
|
||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.clientId = props.item.client?.id ?? null
|
||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||
} else {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
const [loadedClients, loadedProjects] = await Promise.all([
|
||||
useClientService().getAll(),
|
||||
useProjectService().getAll({ archived: false }),
|
||||
])
|
||||
clients.value = loadedClients
|
||||
allProjects.value = loadedProjects
|
||||
}
|
||||
})
|
||||
|
||||
@@ -113,9 +188,11 @@ async function handleSubmit() {
|
||||
const payload: UserWrite = {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
|
||||
}
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
payload.plainPassword = form.password
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
|
||||
@@ -29,13 +29,14 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||
toastSuccessKey?: string
|
||||
}
|
||||
|
||||
export const useApi = (): ApiClient => {
|
||||
let isHandlingUnauthorized = false
|
||||
|
||||
export function useApi(): ApiClient {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
let isHandlingUnauthorized = false
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
t: (key: string) => string
|
||||
@@ -45,7 +46,7 @@ export const useApi = (): ApiClient => {
|
||||
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
||||
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
||||
|
||||
const extractErrorMessage = (error: unknown, responseData?: unknown): string => {
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
@@ -169,20 +170,23 @@ export const useApi = (): ApiClient => {
|
||||
}
|
||||
})
|
||||
|
||||
const request = <T>(
|
||||
function request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
options: ApiFetchOptions<'json'> = {}
|
||||
) => {
|
||||
) {
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
if (!isFormData) {
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const useAppVersion = () => {
|
||||
export function useAppVersion() {
|
||||
const api = useApi()
|
||||
const version = useState<string | null>('app-version', () => null)
|
||||
|
||||
const load = async () => {
|
||||
async function load(): Promise<string | null> {
|
||||
if (version.value) {
|
||||
return version.value
|
||||
}
|
||||
|
||||
26
frontend/composables/useAvatarService.ts
Normal file
26
frontend/composables/useAvatarService.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function useAvatarService() {
|
||||
const api = useApi()
|
||||
|
||||
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, 'avatar.png')
|
||||
|
||||
return api.post<{ avatarUrl: string }>(
|
||||
`/users/${userId}/avatar`,
|
||||
formData as unknown as Record<string, unknown>,
|
||||
{
|
||||
toastSuccessKey: 'profile.avatarUpdated',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function remove(userId: number): Promise<void> {
|
||||
await api.delete(`/users/${userId}/avatar`)
|
||||
}
|
||||
|
||||
function getUrl(userId: number): string {
|
||||
return `/api/users/${userId}/avatar`
|
||||
}
|
||||
|
||||
return { upload, remove, getUrl }
|
||||
}
|
||||
48
frontend/composables/useClientTicketHelpers.ts
Normal file
48
frontend/composables/useClientTicketHelpers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
|
||||
export function useClientTicketHelpers() {
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'bug': return 'bg-red-500'
|
||||
case 'improvement': return 'bg-blue-500'
|
||||
default: return 'bg-neutral-500'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAvailableStatusTransitions(
|
||||
current: ClientTicketStatus,
|
||||
t: (key: string) => string,
|
||||
): { label: string; value: ClientTicketStatus }[] {
|
||||
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
||||
{ label: t('clientTicket.status.new'), value: 'new' },
|
||||
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
||||
{ label: t('clientTicket.status.done'), value: 'done' },
|
||||
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
||||
]
|
||||
return allStatuses.filter(s => {
|
||||
if (s.value === current) return false
|
||||
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
|
||||
}
|
||||
69
frontend/composables/useNotifications.ts
Normal file
69
frontend/composables/useNotifications.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Notification } from '~/services/dto/notification'
|
||||
import { useNotificationService } from '~/services/notifications'
|
||||
|
||||
const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes
|
||||
|
||||
export function useNotifications() {
|
||||
const unreadCount = useState<number>('notification-unread-count', () => 0)
|
||||
const notifications = useState<Notification[]>('notification-list', () => [])
|
||||
const isLoading = useState<boolean>('notification-loading', () => false)
|
||||
|
||||
const service = useNotificationService()
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
unreadCount.value = await service.getUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
notifications.value = await service.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
await service.markAsRead(id)
|
||||
const notif = notifications.value.find(n => n.id === id)
|
||||
if (notif && !notif.isRead) {
|
||||
notif.isRead = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead(): Promise<void> {
|
||||
await service.markAllAsRead()
|
||||
notifications.value.forEach(n => n.isRead = true)
|
||||
unreadCount.value = 0
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
fetchUnreadCount()
|
||||
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
notifications,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
@@ -22,39 +22,77 @@
|
||||
"clients": {
|
||||
"created": "Client créé avec succès.",
|
||||
"updated": "Client mis à jour avec succès.",
|
||||
"deleted": "Client supprimé avec succès."
|
||||
"deleted": "Client supprimé avec succès.",
|
||||
"addClient": "Ajouter un client",
|
||||
"editClient": "Modifier un client"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projets",
|
||||
"created": "Projet créé avec succès.",
|
||||
"updated": "Projet mis à jour avec succès.",
|
||||
"deleted": "Projet supprimé avec succès."
|
||||
"deleted": "Projet supprimé avec succès.",
|
||||
"archived": "Projet archivé avec succès.",
|
||||
"unarchived": "Projet désarchivé avec succès.",
|
||||
"showArchived": "Voir les projets archivés",
|
||||
"hideArchived": "Masquer les projets archivés",
|
||||
"noProjects": "Aucun projet trouvé.",
|
||||
"noArchivedProjects": "Aucun projet archivé.",
|
||||
"addProject": "Ajouter un projet",
|
||||
"addProjectShort": "Projet",
|
||||
"editProject": "Modifier un projet"
|
||||
},
|
||||
"taskStatuses": {
|
||||
"created": "Statut créé avec succès.",
|
||||
"updated": "Statut mis à jour avec succès.",
|
||||
"deleted": "Statut supprimé avec succès."
|
||||
"deleted": "Statut supprimé avec succès.",
|
||||
"addStatus": "Ajouter un statut",
|
||||
"editStatus": "Modifier un statut",
|
||||
"deleteStatus": "Supprimer le statut « {label} »",
|
||||
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
|
||||
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
|
||||
"moveTo": "Déplacer vers",
|
||||
"backlog": "Backlog (sans statut)"
|
||||
},
|
||||
"taskEfforts": {
|
||||
"created": "Effort créé avec succès.",
|
||||
"updated": "Effort mis à jour avec succès.",
|
||||
"deleted": "Effort supprimé avec succès."
|
||||
"deleted": "Effort supprimé avec succès.",
|
||||
"addEffort": "Ajouter un effort",
|
||||
"editEffort": "Modifier un effort"
|
||||
},
|
||||
"taskPriorities": {
|
||||
"created": "Priorité créée avec succès.",
|
||||
"updated": "Priorité mise à jour avec succès.",
|
||||
"deleted": "Priorité supprimée avec succès."
|
||||
"deleted": "Priorité supprimée avec succès.",
|
||||
"addPriority": "Ajouter une priorité",
|
||||
"editPriority": "Modifier une priorité"
|
||||
},
|
||||
"taskTags": {
|
||||
"created": "Tag créé avec succès.",
|
||||
"updated": "Tag mis à jour avec succès.",
|
||||
"deleted": "Tag supprimé avec succès."
|
||||
"deleted": "Tag supprimé avec succès.",
|
||||
"addTag": "Ajouter un tag",
|
||||
"editTag": "Modifier un tag"
|
||||
},
|
||||
"taskGroups": {
|
||||
"created": "Groupe créé avec succès.",
|
||||
"updated": "Groupe mis à jour avec succès.",
|
||||
"deleted": "Groupe supprimé avec succès.",
|
||||
"archived": "Groupe archivé avec succès.",
|
||||
"unarchived": "Groupe désarchivé avec succès."
|
||||
"unarchived": "Groupe désarchivé avec succès.",
|
||||
"addGroup": "Ajouter un groupe",
|
||||
"editGroup": "Modifier un groupe"
|
||||
},
|
||||
"taskDocuments": {
|
||||
"title": "Documents",
|
||||
"dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner",
|
||||
"uploaded": "Document ajouté avec succès.",
|
||||
"deleted": "Document supprimé avec succès.",
|
||||
"uploadError": "Erreur lors de l'upload du document.",
|
||||
"confirmDeleteTitle": "Supprimer le document",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
|
||||
"download": "Télécharger",
|
||||
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
|
||||
},
|
||||
"tasks": {
|
||||
"created": "Ticket créé avec succès.",
|
||||
@@ -63,17 +101,24 @@
|
||||
"archived": "Ticket archivé avec succès.",
|
||||
"unarchived": "Ticket désarchivé avec succès.",
|
||||
"deleteConfirmTitle": "Supprimer le ticket",
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"addTask": "Ajouter un ticket",
|
||||
"editTask": "Modifier un ticket"
|
||||
},
|
||||
"users": {
|
||||
"created": "Utilisateur créé avec succès.",
|
||||
"updated": "Utilisateur mis à jour avec succès.",
|
||||
"deleted": "Utilisateur supprimé avec succès."
|
||||
"deleted": "Utilisateur supprimé avec succès.",
|
||||
"addUser": "Ajouter un utilisateur",
|
||||
"editUser": "Modifier un utilisateur"
|
||||
},
|
||||
"timeEntries": {
|
||||
"created": "Temps enregistré",
|
||||
"updated": "Temps modifié",
|
||||
"deleted": "Temps supprimé"
|
||||
"deleted": "Temps supprimé",
|
||||
"noEntries": "Aucune activité pour cette période",
|
||||
"addEntry": "Ajouter une Activité",
|
||||
"editEntry": "Modifier un temps"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
@@ -97,14 +142,75 @@
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
"backlog": "Backlog",
|
||||
"createTask": "Créer une tâche"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"noData": "Aucune donnée",
|
||||
"noPriority": "Sans priorité",
|
||||
"noProject": "Sans projet",
|
||||
"hoursWorked": "Heures travaillées",
|
||||
"inProgress": "En cours",
|
||||
"done": "Terminé",
|
||||
"filters": {
|
||||
"period": "Période",
|
||||
"project": "Projet",
|
||||
"user": "Utilisateur",
|
||||
"allProjects": "Tous les projets",
|
||||
"allUsers": "Tous les utilisateurs"
|
||||
},
|
||||
"periods": {
|
||||
"thisWeek": "Cette semaine",
|
||||
"lastWeek": "Semaine dernière",
|
||||
"thisMonth": "Ce mois",
|
||||
"lastMonth": "Mois dernier"
|
||||
},
|
||||
"stats": {
|
||||
"hoursPeriod": "Heures sur la période",
|
||||
"myActiveTasks": "Mes tâches actives",
|
||||
"completed": "terminée(s)",
|
||||
"totalTasks": "Tâches totales",
|
||||
"unassigned": "non assignée(s)",
|
||||
"projects": "Projets",
|
||||
"users": "utilisateur(s)"
|
||||
},
|
||||
"charts": {
|
||||
"hoursByDay": "Heures par jour",
|
||||
"hoursByProject": "Temps par projet",
|
||||
"tasksByStatus": "Tâches par statut",
|
||||
"tasksByPriority": "Tâches par priorité",
|
||||
"tasksByProject": "Tâches par projet"
|
||||
},
|
||||
"days": {
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Jeu",
|
||||
"fri": "Ven",
|
||||
"sat": "Sam",
|
||||
"sun": "Dim"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"loading": "Chargement..."
|
||||
"save": "Enregistrer",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"add": "Ajouter",
|
||||
"loading": "Chargement...",
|
||||
"archived": "Archivé",
|
||||
"noClient": "Aucun client",
|
||||
"untitled": "Sans titre",
|
||||
"dateFilter": "Date",
|
||||
"today": "Aujourd'hui",
|
||||
"thisWeek": "Cette semaine",
|
||||
"clear": "Effacer",
|
||||
"day": "Jour",
|
||||
"weekShort": "Sem."
|
||||
},
|
||||
"gitea": {
|
||||
"settings": {
|
||||
@@ -150,5 +256,104 @@
|
||||
},
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail client",
|
||||
"projects": "Vos projets",
|
||||
"noProjects": "Aucun projet disponible.",
|
||||
"openTickets": "tickets ouverts",
|
||||
"newTicket": "Nouveau ticket",
|
||||
"ticketDetail": "Détail du ticket",
|
||||
"backToProject": "Retour au projet",
|
||||
"submitTicket": "Soumettre le ticket",
|
||||
"ticketCreated": "Ticket soumis avec succès."
|
||||
},
|
||||
"clientTicket": {
|
||||
"title": "Tickets",
|
||||
"new": "Nouveau ticket",
|
||||
"created": "Ticket créé avec succès.",
|
||||
"deleted": "Ticket supprimé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
"statusUpdated": "Statut du ticket mis à jour.",
|
||||
"type": {
|
||||
"bug": "Bug",
|
||||
"improvement": "Amélioration",
|
||||
"other": "Autre"
|
||||
},
|
||||
"status": {
|
||||
"new": "Nouveau",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminé",
|
||||
"rejected": "Rejeté"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
"url": "URL de la page",
|
||||
"urlPlaceholder": "https://example.com/page-concernee",
|
||||
"type": "Type",
|
||||
"project": "Projet"
|
||||
},
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
|
||||
"rejectComment": "Commentaire de rejet",
|
||||
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
|
||||
"linkedTicket": "Lié au ticket client CT-{number}",
|
||||
"description": "Description",
|
||||
"url": "URL (page concernée)",
|
||||
"statusComment": "Commentaire de statut",
|
||||
"statusChanged": "Statut mis à jour",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"linkedTooltip": "Lié au ticket client {number}",
|
||||
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
||||
"noTickets": "Aucun ticket.",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allProjects": "Tous les projets",
|
||||
"submittedBy": "Soumis par",
|
||||
"createdAt": "Créé le",
|
||||
"adminTab": "Tickets client",
|
||||
"selectType": "Type de ticket",
|
||||
"changeStatus": "Changer le statut"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
"empty": "Aucune notification",
|
||||
"ticketCreated": "Nouveau ticket client {number}",
|
||||
"ticketStatusChanged": "Ticket {number} mis à jour",
|
||||
"timeAgo": {
|
||||
"now": "À l'instant",
|
||||
"minutes": "Il y a {n} min",
|
||||
"hours": "Il y a {n}h",
|
||||
"days": "Il y a {n}j"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Mon profil",
|
||||
"changeAvatar": "Changer l'avatar",
|
||||
"removeAvatar": "Supprimer l'avatar",
|
||||
"cropAvatar": "Recadrer l'avatar"
|
||||
},
|
||||
"bookstack": {
|
||||
"settings": {
|
||||
"title": "Configuration BookStack",
|
||||
"url": "URL du serveur",
|
||||
"urlPlaceholder": "https://wiki.example.com",
|
||||
"tokenId": "Token ID",
|
||||
"tokenIdPlaceholder": "Entrez le Token ID",
|
||||
"tokenSecret": "Token Secret",
|
||||
"tokenSecretPlaceholder": "Entrez le Token Secret",
|
||||
"tokenConfigured": "Token configuré",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration BookStack sauvegardée.",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie.",
|
||||
"testFailed": "Connexion échouée."
|
||||
},
|
||||
"links": {
|
||||
"title": "Documentation",
|
||||
"searchPlaceholder": "Rechercher une page ou un livre...",
|
||||
"noResults": "Aucun résultat",
|
||||
"empty": "Aucun document lié"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,3 @@
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { version } = useAppVersion()
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-all duration-300"
|
||||
:class="ui.sidebarCollapsed ? 'w-16' : 'w-64'"
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="[
|
||||
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center overflow-hidden" :class="ui.sidebarCollapsed ? 'p-2' : ''">
|
||||
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
|
||||
<img
|
||||
v-if="!ui.sidebarCollapsed"
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
alt="Logo"
|
||||
class="w-auto"
|
||||
@@ -18,49 +30,61 @@
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-cover object-left"
|
||||
/>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-hidden" :class="ui.sidebarCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<SidebarLink
|
||||
to="/"
|
||||
icon="mdi:question-mark"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
label="Tableau de bord"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
label="Projets"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}`"
|
||||
icon="mdi:view-column-outline"
|
||||
label="Kanban"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
exact
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/groups`"
|
||||
icon="mdi:tag-multiple-outline"
|
||||
label="Groupes"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/archives`"
|
||||
icon="mdi:archive-outline"
|
||||
label="Archives"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
</template>
|
||||
@@ -68,24 +92,26 @@
|
||||
to="/time-tracking"
|
||||
icon="mdi:clock-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3">
|
||||
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
|
||||
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p v-if="!ui.sidebarCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors"
|
||||
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
@@ -97,10 +123,10 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex-1 overflow-y-auto bg-white px-16 pb-24">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 bg-white" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -122,6 +148,7 @@ import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -129,6 +156,17 @@ const ui = useUiStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
return ui.sidebarCollapsed
|
||||
})
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
|
||||
const currentProjectId = computed(() => {
|
||||
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||
return match ? match[1] : null
|
||||
@@ -174,9 +212,9 @@ async function loadRefData() {
|
||||
if (refData.loaded) return
|
||||
const api = useApi()
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
api.get<any>('/users'),
|
||||
api.get<any>('/projects'),
|
||||
api.get<any>('/task_tags'),
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
])
|
||||
refData.users = extractHydraMembers(usersData)
|
||||
refData.projects = extractHydraMembers(projectsData)
|
||||
@@ -205,9 +243,15 @@ function onCompleteSaved() {
|
||||
timerStore.clearPendingEntry()
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
87
frontend/layouts/portal.vue
Normal file
87
frontend/layouts/portal.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto" />
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<SidebarLink
|
||||
to="/portal"
|
||||
icon="mdi:folder-outline"
|
||||
label="Mes projets"
|
||||
:collapsed="false"
|
||||
class="border-t border-secondary-500 pt-6"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/"
|
||||
icon="mdi:shield-crown-outline"
|
||||
label="Administration"
|
||||
:collapsed="false"
|
||||
class="mt-2"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const { version } = useAppVersion()
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
7
frontend/middleware/admin.ts
Normal file
7
frontend/middleware/admin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -1,16 +1,25 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
const isClientOnly = auth.isAuthenticated
|
||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
const isProfileRoute = to.path === '/profile'
|
||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||
return navigateTo('/portal')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,14 +23,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
],
|
||||
@@ -62,5 +54,8 @@ export default defineNuxtConfig({
|
||||
},
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
build: {
|
||||
transpile: ['@vuepic/vue-datepicker']
|
||||
}
|
||||
})
|
||||
|
||||
301
frontend/package-lock.json
generated
301
frontend/package-lock.json
generated
@@ -12,10 +12,14 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
},
|
||||
@@ -72,6 +76,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -538,6 +543,12 @@
|
||||
"postcss-selector-parser": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dxup/nuxt": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
|
||||
@@ -1027,7 +1038,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
@@ -1037,7 +1047,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"debug": "^4.3.1",
|
||||
@@ -1052,7 +1061,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
},
|
||||
@@ -1065,7 +1073,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
@@ -1078,7 +1085,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
@@ -1088,7 +1094,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"levn": "^0.4.1"
|
||||
@@ -1097,12 +1102,73 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
|
||||
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
@@ -1112,7 +1178,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
@@ -1126,7 +1191,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
},
|
||||
@@ -1140,7 +1204,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
@@ -2118,6 +2181,12 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
@@ -2414,6 +2483,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
||||
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -2486,6 +2556,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
||||
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.27",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3132,6 +3203,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
||||
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.95.0"
|
||||
},
|
||||
@@ -5237,8 +5309,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -5250,8 +5321,7 @@
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
@@ -5259,6 +5329,12 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||
@@ -5586,6 +5662,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
@@ -5719,6 +5796,62 @@
|
||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vuepic/vue-datepicker": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
|
||||
"integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@floating-ui/vue": "^1.1.9",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
|
||||
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.2.1",
|
||||
"@vueuse/shared": "14.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
|
||||
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
|
||||
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||
@@ -5779,6 +5912,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5818,7 +5952,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6150,6 +6283,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -6343,6 +6477,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6471,6 +6606,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6607,6 +6743,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -6636,10 +6785,17 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clipboardy": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
|
||||
@@ -7108,6 +7264,16 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/db0": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||
@@ -7142,6 +7308,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -7169,8 +7341,7 @@
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
@@ -7420,6 +7591,12 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/easy-bem": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
|
||||
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -7670,7 +7847,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -7701,7 +7877,6 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -7714,7 +7889,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -7727,7 +7901,6 @@
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
@@ -7740,7 +7913,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -7750,7 +7922,6 @@
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
@@ -7768,7 +7939,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -7794,7 +7964,6 @@
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
@@ -7807,7 +7976,6 @@
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
@@ -7908,8 +8076,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -7937,15 +8104,13 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-npm-meta": {
|
||||
"version": "1.4.0",
|
||||
@@ -7990,7 +8155,6 @@
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
@@ -8021,7 +8185,6 @@
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
@@ -8038,7 +8201,6 @@
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
@@ -8051,8 +8213,7 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
@@ -8585,7 +8746,6 @@
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
@@ -8962,22 +9122,19 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -9055,7 +9212,6 @@
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
@@ -9316,7 +9472,6 @@
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
@@ -9401,7 +9556,6 @@
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
@@ -9793,8 +9947,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
@@ -10030,6 +10183,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
||||
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.3.2",
|
||||
"@nuxt/cli": "^3.33.0",
|
||||
@@ -10300,7 +10454,6 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -10352,6 +10505,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -10435,7 +10589,6 @@
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -10451,7 +10604,6 @@
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
@@ -10494,7 +10646,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -10598,6 +10749,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -10714,6 +10866,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11257,6 +11410,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -11307,7 +11461,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
@@ -11344,7 +11497,6 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -11706,6 +11858,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -12488,6 +12641,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12828,7 +12982,6 @@
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
@@ -12896,6 +13049,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13331,7 +13485,6 @@
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
@@ -13356,6 +13509,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -13717,6 +13871,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
@@ -13733,6 +13888,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-advanced-cropper": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.6",
|
||||
"debounce": "^1.2.0",
|
||||
"easy-bem": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-bundle-renderer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",
|
||||
@@ -13742,6 +13915,16 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-devtools-stub": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
|
||||
@@ -13753,6 +13936,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
@@ -13774,6 +13958,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -13826,7 +14011,6 @@
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13995,7 +14179,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-500">Administration</h1>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
|
||||
|
||||
<div class="mt-6 border-b border-neutral-200">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div>
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const tabs = [
|
||||
@@ -40,7 +45,9 @@ const tabs = [
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'client-tickets', label: 'Tickets client' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -1,7 +1,668 @@
|
||||
<template>
|
||||
<h1 class="text-primary-500">Tableau de bord</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doughnut, Bar, Line } from 'vue-chartjs'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('dashboard.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const timeEntryService = useTimeEntryService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
|
||||
const allTasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const allTimeEntries = ref<TimeEntry[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// ── Filters ──
|
||||
|
||||
type PeriodKey = 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
|
||||
|
||||
const selectedPeriod = ref<PeriodKey>('thisWeek')
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
|
||||
const periodOptions = computed(() => [
|
||||
{ label: t('dashboard.periods.thisWeek'), value: 'thisWeek' },
|
||||
{ label: t('dashboard.periods.lastWeek'), value: 'lastWeek' },
|
||||
{ label: t('dashboard.periods.thisMonth'), value: 'thisMonth' },
|
||||
{ label: t('dashboard.periods.lastMonth'), value: 'lastMonth' },
|
||||
])
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// ── Period date ranges ──
|
||||
|
||||
function getWeekRange(offset: number = 0) {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() + diffToMonday + offset * 7)
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
sunday.setHours(23, 59, 59, 999)
|
||||
return { start: monday, end: sunday }
|
||||
}
|
||||
|
||||
function getMonthRange(offset: number = 0) {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const dateRange = computed(() => {
|
||||
switch (selectedPeriod.value) {
|
||||
case 'thisWeek': return getWeekRange(0)
|
||||
case 'lastWeek': return getWeekRange(-1)
|
||||
case 'thisMonth': return getMonthRange(0)
|
||||
case 'lastMonth': return getMonthRange(-1)
|
||||
}
|
||||
})
|
||||
|
||||
const isWeekPeriod = computed(() =>
|
||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||
)
|
||||
|
||||
// ── Filtered data (client-side project filter) ──
|
||||
|
||||
const tasks = computed(() => {
|
||||
if (!selectedProjectId.value) return allTasks.value
|
||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
||||
})
|
||||
|
||||
const timeEntries = computed(() => {
|
||||
if (!selectedProjectId.value) return allTimeEntries.value
|
||||
return allTimeEntries.value.filter(e => e.project?.id === selectedProjectId.value)
|
||||
})
|
||||
|
||||
// ── Data loading ──
|
||||
|
||||
async function loadReferenceData() {
|
||||
const [s, p, proj, u] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
priorityService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
priorities.value = p
|
||||
projects.value = proj
|
||||
users.value = u
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
allTasks.value = await taskService.getFiltered({ archived: false })
|
||||
}
|
||||
|
||||
async function loadTimeEntries() {
|
||||
const params: { after: string; before: string; user?: number } = {
|
||||
after: dateRange.value.start.toISOString(),
|
||||
before: dateRange.value.end.toISOString(),
|
||||
}
|
||||
if (selectedUserId.value) {
|
||||
params.user = selectedUserId.value
|
||||
}
|
||||
allTimeEntries.value = await timeEntryService.getByDateRange(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks(), loadTimeEntries()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reload time entries when period or user changes (server-side filter)
|
||||
watch([selectedPeriod, selectedUserId], () => {
|
||||
loadTimeEntries()
|
||||
})
|
||||
|
||||
onMounted(() => loadAll())
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function durationHours(entry: TimeEntry): number {
|
||||
const start = new Date(entry.startedAt)
|
||||
const end = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
|
||||
return (end.getTime() - start.getTime()) / 3_600_000
|
||||
}
|
||||
|
||||
function formatHours(h: number): string {
|
||||
const hours = Math.floor(h)
|
||||
const mins = Math.round((h - hours) * 60)
|
||||
return mins > 0 ? `${hours}h${String(mins).padStart(2, '0')}` : `${hours}h`
|
||||
}
|
||||
|
||||
// ── KPI Stats ──
|
||||
|
||||
const totalHoursThisWeek = computed(() =>
|
||||
timeEntries.value.reduce((sum, e) => sum + durationHours(e), 0)
|
||||
)
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
||||
)
|
||||
|
||||
const myTasksDone = computed(() =>
|
||||
myTasks.value.filter(t => t.status?.isFinal)
|
||||
)
|
||||
|
||||
const unassignedTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.assignee)
|
||||
)
|
||||
|
||||
// ── Chart: Tasks by Status (Doughnut) ──
|
||||
|
||||
const tasksByStatusData = computed(() => {
|
||||
const sorted = [...statuses.value].sort((a, b) => a.position - b.position)
|
||||
const noStatus = tasks.value.filter(t => !t.status).length
|
||||
const labels = noStatus > 0 ? ['Backlog', ...sorted.map(s => s.label)] : sorted.map(s => s.label)
|
||||
const data = noStatus > 0
|
||||
? [noStatus, ...sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)]
|
||||
: sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)
|
||||
const colors = noStatus > 0
|
||||
? ['#9ca3af', ...sorted.map(s => s.color)]
|
||||
: sorted.map(s => s.color)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Tasks by Priority (Bar) ──
|
||||
|
||||
const tasksByPriorityData = computed(() => {
|
||||
const sorted = [...priorities.value]
|
||||
const noPriority = tasks.value.filter(t => !t.priority).length
|
||||
const labels = [...sorted.map(p => p.label), ...(noPriority > 0 ? [t('dashboard.noPriority')] : [])]
|
||||
const data = [...sorted.map(p => tasks.value.filter(t => t.priority?.id === p.id).length), ...(noPriority > 0 ? [noPriority] : [])]
|
||||
const colors = [...sorted.map(p => p.color), ...(noPriority > 0 ? ['#9ca3af'] : [])]
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Hours by Project (Doughnut) ──
|
||||
|
||||
const hoursByProjectData = computed(() => {
|
||||
const projectHours = new Map<number, { name: string; color: string; hours: number }>()
|
||||
let noProjectHours = 0
|
||||
|
||||
for (const entry of timeEntries.value) {
|
||||
const h = durationHours(entry)
|
||||
if (entry.project) {
|
||||
const existing = projectHours.get(entry.project.id)
|
||||
if (existing) {
|
||||
existing.hours += h
|
||||
} else {
|
||||
projectHours.set(entry.project.id, {
|
||||
name: entry.project.name,
|
||||
color: entry.project.color || '#6366f1',
|
||||
hours: h,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
noProjectHours += h
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...projectHours.values()].sort((a, b) => b.hours - a.hours)
|
||||
if (noProjectHours > 0) {
|
||||
entries.push({ name: t('dashboard.noProject'), color: '#9ca3af', hours: noProjectHours })
|
||||
}
|
||||
|
||||
return {
|
||||
labels: entries.map(e => e.name),
|
||||
datasets: [{
|
||||
data: entries.map(e => Math.round(e.hours * 100) / 100),
|
||||
backgroundColor: entries.map(e => e.color),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Hours by Day (Line) ──
|
||||
|
||||
const weekDayLabels = [
|
||||
t('dashboard.days.mon'),
|
||||
t('dashboard.days.tue'),
|
||||
t('dashboard.days.wed'),
|
||||
t('dashboard.days.thu'),
|
||||
t('dashboard.days.fri'),
|
||||
t('dashboard.days.sat'),
|
||||
t('dashboard.days.sun'),
|
||||
]
|
||||
|
||||
const hoursByDayData = computed(() => {
|
||||
if (isWeekPeriod.value) {
|
||||
const dayHours = new Array(7).fill(0)
|
||||
for (const entry of timeEntries.value) {
|
||||
const start = new Date(entry.startedAt)
|
||||
const dayIndex = start.getDay() === 0 ? 6 : start.getDay() - 1
|
||||
dayHours[dayIndex] += durationHours(entry)
|
||||
}
|
||||
return {
|
||||
labels: weekDayLabels,
|
||||
datasets: [{
|
||||
label: t('dashboard.hoursWorked'),
|
||||
data: dayHours.map(h => Math.round(h * 100) / 100),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
pointRadius: 4,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// Month view: group by week number
|
||||
const { start, end } = dateRange.value
|
||||
const weekMap = new Map<string, number>()
|
||||
const weekLabels: string[] = []
|
||||
|
||||
// Build week labels for the month
|
||||
const cursor = new Date(start)
|
||||
while (cursor <= end) {
|
||||
const weekStart = new Date(cursor)
|
||||
const weekEnd = new Date(cursor)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
if (weekEnd > end) weekEnd.setTime(end.getTime())
|
||||
const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1} - ${weekEnd.getDate()}/${weekEnd.getMonth() + 1}`
|
||||
weekLabels.push(label)
|
||||
weekMap.set(label, 0)
|
||||
cursor.setDate(cursor.getDate() + 7)
|
||||
// Align to Monday
|
||||
const d = cursor.getDay()
|
||||
if (d !== 1) {
|
||||
cursor.setDate(cursor.getDate() + (d === 0 ? 1 : 8 - d))
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of timeEntries.value) {
|
||||
const entryDate = new Date(entry.startedAt)
|
||||
for (let i = 0; i < weekLabels.length; i++) {
|
||||
const parts = weekLabels[i].split(' - ')
|
||||
const [sd, sm] = parts[0].split('/').map(Number)
|
||||
const [ed, em] = parts[1].split('/').map(Number)
|
||||
const ws = new Date(start.getFullYear(), sm - 1, sd)
|
||||
const we = new Date(start.getFullYear(), em - 1, ed, 23, 59, 59)
|
||||
if (entryDate >= ws && entryDate <= we) {
|
||||
weekMap.set(weekLabels[i], (weekMap.get(weekLabels[i]) ?? 0) + durationHours(entry))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: weekLabels,
|
||||
datasets: [{
|
||||
label: t('dashboard.hoursWorked'),
|
||||
data: weekLabels.map(l => Math.round((weekMap.get(l) ?? 0) * 100) / 100),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
pointRadius: 4,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Tasks by Project (Horizontal Bar) ──
|
||||
|
||||
const tasksByProjectData = computed(() => {
|
||||
const projectTasks = new Map<number, { name: string; color: string; count: number; done: number }>()
|
||||
|
||||
for (const task of tasks.value) {
|
||||
if (!task.project) continue
|
||||
const existing = projectTasks.get(task.project.id)
|
||||
const isDone = task.status?.isFinal ?? false
|
||||
if (existing) {
|
||||
existing.count++
|
||||
if (isDone) existing.done++
|
||||
} else {
|
||||
projectTasks.set(task.project.id, {
|
||||
name: task.project.name,
|
||||
color: task.project.color || '#6366f1',
|
||||
count: 1,
|
||||
done: isDone ? 1 : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...projectTasks.values()].sort((a, b) => b.count - a.count)
|
||||
|
||||
return {
|
||||
labels: entries.map(e => e.name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.inProgress'),
|
||||
data: entries.map(e => e.count - e.done),
|
||||
backgroundColor: entries.map(e => e.color),
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.done'),
|
||||
data: entries.map(e => e.done),
|
||||
backgroundColor: entries.map(e => e.color + '66'),
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart options ──
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const barOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { stepSize: 1 },
|
||||
grid: { color: '#f3f4f6' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const horizontalBarOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y' as const,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true,
|
||||
ticks: { stepSize: 1 },
|
||||
grid: { color: '#f3f4f6' },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f3f4f6' },
|
||||
ticks: {
|
||||
callback: (value: number | string) => `${value}h`,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
min-width="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||
<p class="text-neutral-400">{{ $t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- KPI Cards -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.hoursPeriod') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ formatHours(totalHoursThisWeek) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.myActiveTasks') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ myTasks.length - myTasksDone.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ myTasksDone.length }} {{ $t('dashboard.stats.completed') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.totalTasks') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ tasks.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ unassignedTasks.length }} {{ $t('dashboard.stats.unassigned') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.projects') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ projects.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ users.length }} {{ $t('dashboard.stats.users') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<!-- Hours by Day (Line) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.hoursByDay') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Line :data="hoursByDayData" :options="lineOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hours by Project (Doughnut) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.hoursByProject') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Doughnut
|
||||
v-if="hoursByProjectData.labels.length > 0"
|
||||
:data="hoursByProjectData"
|
||||
:options="doughnutOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<!-- Tasks by Status (Doughnut) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByStatus') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Doughnut
|
||||
v-if="tasksByStatusData.labels.length > 0"
|
||||
:data="tasksByStatusData"
|
||||
:options="doughnutOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks by Priority (Bar) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByPriority') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Bar
|
||||
v-if="tasksByPriorityData.labels.length > 0"
|
||||
:data="tasksByPriorityData"
|
||||
:options="barOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 3 -->
|
||||
<div class="mt-6">
|
||||
<!-- Tasks by Project (Horizontal Bar) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByProject') }}
|
||||
</h2>
|
||||
<div class="mt-4" :style="{ height: Math.max(200, tasksByProjectData.labels.length * 40 + 60) + 'px' }">
|
||||
<Bar
|
||||
v-if="tasksByProjectData.labels.length > 0"
|
||||
:data="tasksByProjectData"
|
||||
:options="horizontalBarOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,7 +48,6 @@ useHead({
|
||||
title: 'Connexion'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
|
||||
@@ -56,14 +55,15 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
await router.push('/')
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await navigateTo(isClient ? '/portal' : '/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
@@ -229,104 +234,147 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<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 class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</button>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewKanban')"
|
||||
@click="viewMode = 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-column-outline" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</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-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</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="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 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="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-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>
|
||||
</div>
|
||||
|
||||
<!-- Backlog below kanban -->
|
||||
<div
|
||||
v-if="backlogTasks.length > 0"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<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">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
@@ -334,39 +382,12 @@ onMounted(() => {
|
||||
@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 transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
<p
|
||||
v-if="backlogTasks.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ 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>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,12 +396,12 @@ onMounted(() => {
|
||||
<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"
|
||||
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
|
||||
@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">
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
@@ -406,12 +427,20 @@ onMounted(() => {
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
@@ -433,6 +462,7 @@ onMounted(() => {
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user