Compare commits
118 Commits
6c910e7fcc
...
v0.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa42b6039 | ||
|
|
7b0c2d9fba | ||
|
|
4ce0214ec9 | ||
|
|
43304bebcc | ||
|
|
6668af73a7 | ||
|
|
ff9a6763c3 | ||
|
|
db5b3d39f9 | ||
|
|
1fdc68c66d | ||
|
|
99b664cdd8 | ||
|
|
fd1da75fd7 | ||
|
|
66264e3b8c | ||
|
|
a89fa6a7af | ||
|
|
6862944726 | ||
|
|
e00c33d20b | ||
|
|
1aa72c3b56 | ||
|
|
6a8e406cc5 | ||
|
|
83b42139b2 | ||
|
|
1bdd3883aa | ||
|
|
22c3c3dbd1 | ||
|
|
cb768e0ce1 | ||
|
|
b3d317284e | ||
|
|
5a47adace5 | ||
|
|
75c53632c8 | ||
|
|
97a8afe559 | ||
|
|
bae6d10ece | ||
|
|
a0306bb5b2 | ||
|
|
7e36b6fd49 | ||
|
|
e688c69438 | ||
|
|
e640e715bb | ||
|
|
6784ee9ead | ||
|
|
fc6b6587f9 | ||
|
|
aa38e20c00 | ||
|
|
98370e0478 | ||
|
|
30fb36e668 | ||
|
|
bd01072831 | ||
|
|
df58b09c2e | ||
|
|
26c41f01c0 | ||
|
|
b66caf6824 | ||
|
|
96cbb45e61 | ||
|
|
a8b899f7c4 | ||
|
|
766fddd417 | ||
|
|
1219f3e73e | ||
|
|
ec35a1b2aa | ||
|
|
0113c08a60 | ||
|
|
c176511d97 | ||
|
|
64de971872 | ||
|
|
3dcc5c21a2 | ||
|
|
47768c0f02 | ||
|
|
b278b8a23a | ||
|
|
4074457499 | ||
|
|
b29b4d304d | ||
|
|
dd9db93751 | ||
|
|
3e2f3b3cf8 | ||
|
|
5bf768bc02 | ||
|
|
77c7ceb064 | ||
|
|
ac36eeba36 | ||
|
|
005b731a97 | ||
|
|
3df0b15fe7 | ||
|
|
8040245e45 | ||
|
|
5d378c1f75 | ||
|
|
8544babf8c | ||
|
|
455121132d | ||
|
|
fd3097cc26 | ||
|
|
ff7cff1d39 | ||
|
|
ed58a402b0 | ||
|
|
2ac815d074 | ||
|
|
e0dfcbdbf8 | ||
|
|
5db6b1e2b0 | ||
|
|
6e29aeb30f | ||
|
|
cca548dfbc | ||
|
|
3d4b7fad12 | ||
|
|
5ffb4bbedc | ||
|
|
d2e9f9ed65 | ||
|
|
c5898fbf74 | ||
|
|
0180dd3715 | ||
|
|
0f99098291 | ||
|
|
1c6f473dff | ||
|
|
c95fff530c | ||
|
|
fb0e6c1ea4 | ||
|
|
6d3ecc1322 | ||
|
|
f5986090c0 | ||
|
|
d6399c20e1 | ||
|
|
a972d243f5 | ||
|
|
56bf88f293 | ||
| 9d80e017c2 | |||
| 4e91507158 | |||
| 318f14ea88 | |||
| 202b516dc3 | |||
| 98782a9849 | |||
| b978adf9ae | |||
| e4fc34b90f | |||
| a5144443a4 | |||
| afd4baed92 | |||
| e8f0202b15 | |||
| 962b3d935c | |||
| cea22f977b | |||
| 5613a7c92b | |||
| 4d0aa65920 | |||
| 63315c0a15 | |||
| cff16611f4 | |||
| 96f5c7c91c | |||
| f7a76c9e9b | |||
| 7047f64a6b | |||
| cd8cea45c1 | |||
| 1f31a3a33f | |||
| 254f8bc411 | |||
| 239cd6398e | |||
| 318b6198da | |||
| 4e3e854aa2 | |||
| 49cd971e3e | |||
| ffe4a0117c | |||
| d2f6d84d03 | |||
| 2a874046d3 | |||
| f09ef67117 | |||
| 046ee396d3 | |||
| 0ba487cfa9 | |||
| a2fc8e6e52 | |||
|
|
4216f1b5a1 |
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"
|
||||
|
||||
ENCRYPTION_KEY=aaaaaaaaa
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
99
.env.example
Normal file
99
.env.example
Normal file
@@ -0,0 +1,99 @@
|
||||
###############################################################################
|
||||
# Lesstime — Fichier d'environnement de reference
|
||||
#
|
||||
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
|
||||
# Les valeurs par defaut dans .env suffisent pour le developpement ;
|
||||
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
|
||||
# etre definis dans .env.local.
|
||||
#
|
||||
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
|
||||
###############################################################################
|
||||
|
||||
# ===========================================================================
|
||||
# App
|
||||
# ===========================================================================
|
||||
|
||||
# Environnement Symfony : dev, test, prod
|
||||
APP_ENV=dev
|
||||
|
||||
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
|
||||
APP_SECRET="change_me_in_env_local"
|
||||
|
||||
# Active/desactive le mode debug (1 = oui, 0 = non)
|
||||
APP_DEBUG=1
|
||||
|
||||
# URI par defaut de l'application (utilise pour les liens absolus)
|
||||
DEFAULT_URI=http://localhost/
|
||||
|
||||
# ===========================================================================
|
||||
# CORS (nelmio/cors-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Origines autorisees pour les requetes cross-origin (regex)
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
# ===========================================================================
|
||||
# JWT (lexik/jwt-authentication-bundle)
|
||||
# ===========================================================================
|
||||
|
||||
# Chemin vers la cle privee RSA pour signer les tokens JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
|
||||
# Chemin vers la cle publique RSA pour verifier les tokens JWT
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
|
||||
# Passphrase de la cle privee JWT — a generer pour chaque installation
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
JWT_PASSPHRASE=change_me_in_env_local
|
||||
|
||||
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
|
||||
JWT_COOKIE_SECURE=0
|
||||
|
||||
# Duree de vie du token JWT en secondes (86400 = 24h)
|
||||
JWT_TOKEN_TTL=86400
|
||||
|
||||
# Duree de vie du cookie JWT en secondes (86400 = 24h)
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# ===========================================================================
|
||||
# Base de donnees (Doctrine / PostgreSQL)
|
||||
# ===========================================================================
|
||||
|
||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||
# et injectees automatiquement par Docker Compose.
|
||||
# DATABASE_URL est construite a partir de ces variables.
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
# ===========================================================================
|
||||
# Chiffrement
|
||||
# ===========================================================================
|
||||
|
||||
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
|
||||
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
|
||||
# ===========================================================================
|
||||
# Docker (docker/.env.docker)
|
||||
#
|
||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# surcharger localement.
|
||||
# ===========================================================================
|
||||
|
||||
# DOCKER_APP_NAME=lesstime
|
||||
# DOCKER_PHP_VERSION=8.4.6
|
||||
# DOCKER_NODE_VERSION=24.12.0
|
||||
# APP_USER=www-data
|
||||
# POSTGRES_DB=lesstime
|
||||
# POSTGRES_USER=root
|
||||
# POSTGRES_PASSWORD=root
|
||||
# POSTGRES_PORT=5435
|
||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
|
||||
# ===========================================================================
|
||||
# Frontend (frontend/.env)
|
||||
# ===========================================================================
|
||||
|
||||
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
|
||||
# NUXT_PUBLIC_API_BASE=/api
|
||||
@@ -45,12 +45,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
/config/jwt/*.pem
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
###> ide ###
|
||||
.idea/
|
||||
###< ide ###
|
||||
|
||||
###> docker local ###
|
||||
docker/.env.docker.local
|
||||
###< docker local ###
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
8
.idea/Lesstime.iml
generated
8
.idea/Lesstime.iml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/db-forest-config.xml
generated
6
.idea/db-forest-config.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/material_theme_project_new.xml
generated
10
.idea/material_theme_project_new.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="386cba74:19cc24e9181:-799b" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/php.xml
generated
20
.idea/php.xml
generated
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
24
.mcp.json
24
.mcp.json
@@ -1,8 +1,22 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
||||
}
|
||||
},
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec",
|
||||
"-i",
|
||||
"php-lesstime-fpm",
|
||||
"php",
|
||||
"bin/console",
|
||||
"mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -12,9 +12,12 @@ 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, GiteaConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||
src/Enum/ # PHP enums (RecurrenceType)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||
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)
|
||||
@@ -26,12 +29,12 @@ migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/)
|
||||
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/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
||||
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, zimbra, task-recurrences)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -66,6 +69,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
|
||||
|
||||
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
|
||||
### Tags & Versioning
|
||||
|
||||
- La version de l'app est dans `config/version.yaml` (paramètre `app.version`)
|
||||
- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version
|
||||
- Faire un commit séparé de bump : `chore : bump version to v<X.Y.Z>`
|
||||
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
|
||||
|
||||
### Backend
|
||||
|
||||
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
||||
@@ -73,6 +83,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
|
||||
|
||||
@@ -82,10 +99,13 @@ 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
|
||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||
- 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`
|
||||
@@ -111,4 +131,8 @@ 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`
|
||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||
|
||||
190
README.md
190
README.md
@@ -1,10 +1,173 @@
|
||||
# Lesstime
|
||||
|
||||
Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4.
|
||||
Application de gestion de projet avec suivi du temps et portail client.
|
||||
|
||||
## MCP Server
|
||||
## Stack
|
||||
|
||||
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps.
|
||||
| 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)
|
||||
|
||||
@@ -16,13 +179,6 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
|
||||
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
|
||||
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
|
||||
|
||||
### Transports
|
||||
|
||||
| Transport | Usage | Auth |
|
||||
|-----------|-------|------|
|
||||
| **STDIO** | Claude Code sur la machine locale | Aucune |
|
||||
| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer <token>`) |
|
||||
|
||||
### Configuration locale (STDIO)
|
||||
|
||||
```json
|
||||
@@ -55,17 +211,19 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
|
||||
### Gestion des tokens API
|
||||
|
||||
```bash
|
||||
# Générer un token pour un utilisateur
|
||||
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
||||
```
|
||||
|
||||
### Mise en production (réseau local)
|
||||
## 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. `docker restart nginx-lesstime`
|
||||
6. `php bin/console app:generate-api-token admin` — noter le token
|
||||
7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement)
|
||||
8. Configurer les clients MCP avec l'URL `http://<ip-serveur>:8082/_mcp` + le token
|
||||
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.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"sabre/vobject": "^4.5",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
@@ -25,12 +26,14 @@
|
||||
"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.*"
|
||||
},
|
||||
@@ -89,8 +92,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"
|
||||
}
|
||||
}
|
||||
|
||||
1364
composer.lock
generated
1364
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,11 @@ 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],
|
||||
@@ -24,4 +23,5 @@ return [
|
||||
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
|
||||
@@ -19,5 +19,5 @@ mcp:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
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
|
||||
@@ -22,6 +22,9 @@ 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
|
||||
@@ -59,6 +62,7 @@ security:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* limiters?: array<string, array{ // Default: []
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
@@ -685,38 +685,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type TwigConfig = array{
|
||||
* form_themes?: list<scalar|Param|null>,
|
||||
* globals?: array<string, array{ // Default: []
|
||||
* id?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* autoescape_service?: scalar|Param|null, // Default: null
|
||||
* autoescape_service_method?: scalar|Param|null, // Default: null
|
||||
* cache?: scalar|Param|null, // Default: true
|
||||
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
|
||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
|
||||
* auto_reload?: scalar|Param|null,
|
||||
* optimizations?: int|Param,
|
||||
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
||||
* file_name_pattern?: list<scalar|Param|null>,
|
||||
* paths?: array<string, mixed>,
|
||||
* date?: array{ // The default format options used by the date filter.
|
||||
* format?: scalar|Param|null, // Default: "F j, Y H:i"
|
||||
* interval_format?: scalar|Param|null, // Default: "%d days"
|
||||
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
|
||||
* },
|
||||
* number_format?: array{ // The default format options for the number_format filter.
|
||||
* decimals?: int|Param, // Default: 0
|
||||
* decimal_point?: scalar|Param|null, // Default: "."
|
||||
* thousands_separator?: scalar|Param|null, // Default: ","
|
||||
* },
|
||||
* mailer?: array{
|
||||
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
|
||||
* },
|
||||
* }
|
||||
* @psalm-type SecurityConfig = array{
|
||||
* access_denied_url?: scalar|Param|null, // Default: null
|
||||
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
|
||||
@@ -1291,8 +1259,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
|
||||
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
|
||||
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
|
||||
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true
|
||||
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true
|
||||
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
|
||||
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
|
||||
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
|
||||
* enable_docs?: bool|Param, // Enable the docs // Default: true
|
||||
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
|
||||
@@ -1641,12 +1609,154 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* bubble?: bool|Param, // Default: true
|
||||
* interactive_only?: bool|Param, // Default: false
|
||||
* app_name?: scalar|Param|null, // Default: null
|
||||
* include_stacktraces?: bool|Param, // Default: false
|
||||
* process_psr_3_messages?: array{
|
||||
* enabled?: bool|Param|null, // Default: null
|
||||
* date_format?: scalar|Param|null,
|
||||
* remove_used_context_fields?: bool|Param,
|
||||
* },
|
||||
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
* file_permission?: scalar|Param|null, // Default: null
|
||||
* use_locking?: bool|Param, // Default: false
|
||||
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||
* ident?: scalar|Param|null, // Default: false
|
||||
* logopts?: scalar|Param|null, // Default: 1
|
||||
* facility?: scalar|Param|null, // Default: "user"
|
||||
* max_files?: scalar|Param|null, // Default: 0
|
||||
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||
* activation_strategy?: scalar|Param|null, // Default: null
|
||||
* stop_buffering?: bool|Param, // Default: true
|
||||
* passthru_level?: scalar|Param|null, // Default: null
|
||||
* excluded_http_codes?: list<array{ // Default: []
|
||||
* code?: scalar|Param|null,
|
||||
* urls?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* accepted_levels?: list<scalar|Param|null>,
|
||||
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||
* buffer_size?: scalar|Param|null, // Default: 0
|
||||
* flush_on_overflow?: bool|Param, // Default: false
|
||||
* handler?: scalar|Param|null,
|
||||
* url?: scalar|Param|null,
|
||||
* exchange?: scalar|Param|null,
|
||||
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||
* channel?: scalar|Param|null, // Default: null
|
||||
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||
* use_attachment?: scalar|Param|null, // Default: true
|
||||
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||
* include_extra?: scalar|Param|null, // Default: false
|
||||
* icon_emoji?: scalar|Param|null, // Default: null
|
||||
* webhook_url?: scalar|Param|null,
|
||||
* exclude_fields?: list<scalar|Param|null>,
|
||||
* token?: scalar|Param|null,
|
||||
* region?: scalar|Param|null,
|
||||
* source?: scalar|Param|null,
|
||||
* use_ssl?: bool|Param, // Default: true
|
||||
* user?: mixed,
|
||||
* title?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 514
|
||||
* config?: list<scalar|Param|null>,
|
||||
* members?: list<scalar|Param|null>,
|
||||
* connection_string?: scalar|Param|null,
|
||||
* timeout?: scalar|Param|null,
|
||||
* time?: scalar|Param|null, // Default: 60
|
||||
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||
* store?: scalar|Param|null, // Default: null
|
||||
* connection_timeout?: scalar|Param|null,
|
||||
* persistent?: bool|Param,
|
||||
* message_type?: scalar|Param|null, // Default: 0
|
||||
* parse_mode?: scalar|Param|null, // Default: null
|
||||
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||
* disable_notification?: bool|Param|null, // Default: null
|
||||
* split_long_messages?: bool|Param, // Default: false
|
||||
* delay_between_messages?: bool|Param, // Default: false
|
||||
* topic?: int|Param, // Default: null
|
||||
* factor?: int|Param, // Default: 1
|
||||
* tags?: list<scalar|Param|null>,
|
||||
* console_formatter_options?: mixed, // Default: []
|
||||
* formatter?: scalar|Param|null,
|
||||
* nested?: bool|Param, // Default: false
|
||||
* publisher?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hostname?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 12201
|
||||
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||
* encoder?: "json"|"compressed_json"|Param,
|
||||
* },
|
||||
* mongodb?: string|array{
|
||||
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||
* uri?: scalar|Param|null,
|
||||
* username?: scalar|Param|null,
|
||||
* password?: scalar|Param|null,
|
||||
* database?: scalar|Param|null, // Default: "monolog"
|
||||
* collection?: scalar|Param|null, // Default: "logs"
|
||||
* },
|
||||
* elasticsearch?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hosts?: list<scalar|Param|null>,
|
||||
* host?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 9200
|
||||
* transport?: scalar|Param|null, // Default: "Http"
|
||||
* user?: scalar|Param|null, // Default: null
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* index?: scalar|Param|null, // Default: "monolog"
|
||||
* document_type?: scalar|Param|null, // Default: "logs"
|
||||
* ignore_error?: scalar|Param|null, // Default: false
|
||||
* redis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 6379
|
||||
* database?: scalar|Param|null, // Default: 0
|
||||
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||
* },
|
||||
* predis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* },
|
||||
* from_email?: scalar|Param|null,
|
||||
* to_email?: list<scalar|Param|null>,
|
||||
* subject?: scalar|Param|null,
|
||||
* content_type?: scalar|Param|null, // Default: null
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||
* },
|
||||
* channels?: string|array{
|
||||
* type?: scalar|Param|null,
|
||||
* elements?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1654,12 +1764,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1667,13 +1777,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1681,13 +1791,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1695,6 +1805,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# 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 }
|
||||
@@ -39,3 +40,7 @@ services:
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.0'
|
||||
app.version: '0.3.7'
|
||||
|
||||
50
deploy/nginx/lesstime.conf
Normal file
50
deploy/nginx/lesstime.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
DOCKER_APP_NAME=lesstime
|
||||
DOCKER_PHP_VERSION=8.4.6
|
||||
DOCKER_NODE_VERSION=24.12.0
|
||||
APP_USER=www-data
|
||||
POSTGRES_DB=lesstime
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5435
|
||||
XDEBUG_CLIENT_HOST=192.168.0.124
|
||||
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 |
|
||||
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"
|
||||
```
|
||||
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**
|
||||
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
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
|
||||
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Intégration Calendrier Zimbra CalDAV
|
||||
|
||||
**Date** : 2026-03-19
|
||||
**Statut** : Validé
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.
|
||||
|
||||
## Principes
|
||||
|
||||
- **Push uniquement** : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
|
||||
- **Opt-in** : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
|
||||
- **Sync synchrone** : les appels CalDAV se font au moment de l'action, timeout 5s
|
||||
- **Configuration globale** : un seul compte Zimbra admin pour toute l'instance
|
||||
- **Calendrier d'équipe** : toutes les tâches sync vont dans le même calendrier
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Nouveaux champs sur `Task`
|
||||
|
||||
| Champ | Type | Nullable | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `scheduledStart` | `DateTimeImmutable` | oui | `null` | Début du créneau planifié |
|
||||
| `scheduledEnd` | `DateTimeImmutable` | oui | `null` | Fin du créneau planifié |
|
||||
| `deadline` | `DateTimeImmutable` | oui | `null` | Date d'échéance |
|
||||
| `syncToCalendar` | `bool` | non | `false` | Opt-in pour la sync Zimbra |
|
||||
| `calendarEventUid` | `string` | oui | `null` | UID du VEVENT dans Zimbra |
|
||||
| `calendarTodoUid` | `string` | oui | `null` | UID du VTODO dans Zimbra |
|
||||
| `calendarSyncError` | `string` | oui | `null` | Dernière erreur de sync CalDAV (null = OK) |
|
||||
|
||||
#### Règles de validation
|
||||
|
||||
- `scheduledEnd` requiert `scheduledStart` (et vice versa) — les deux ou aucun
|
||||
- `scheduledEnd` doit être après `scheduledStart`
|
||||
- `syncToCalendar = true` sans aucune date → ignoré silencieusement (pas de sync)
|
||||
- `deadline` est indépendant des dates planifiées (peut exister seul)
|
||||
|
||||
### Nouvelle entité `TaskRecurrence`
|
||||
|
||||
| Champ | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `int` | non | PK auto-increment |
|
||||
| `type` | `RecurrenceType` (PHP enum) | non | Enum backed string : `daily`, `weekly`, `monthly`, `yearly` |
|
||||
| `interval` | `int` | non | Tous les X (jours/semaines/mois/ans) |
|
||||
| `daysOfWeek` | `json` | oui | Jours de la semaine pour hebdo, ex: `["monday","wednesday"]` |
|
||||
| `dayOfMonth` | `int` | oui | Jour du mois pour mensuel, ex: `15` |
|
||||
| `weekOfMonth` | `int` | oui | Semaine du mois, ex: `1` pour "le 1er X du mois" |
|
||||
| `endDate` | `Date` | oui | Fin de la récurrence (null = infini) |
|
||||
| `maxOccurrences` | `int` | oui | Nombre max d'occurrences (alternatif à endDate) |
|
||||
| `occurrenceCount` | `int` | non | Compteur d'occurrences créées (default 0) |
|
||||
|
||||
### Relations
|
||||
|
||||
- `Task.recurrence` → `ManyToOne` vers `TaskRecurrence` (nullable)
|
||||
- `TaskRecurrence.tasks` → `OneToMany` vers `Task`
|
||||
|
||||
### Nouvelle entité `ZimbraConfiguration`
|
||||
|
||||
| Champ | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `int` | non | PK auto-increment |
|
||||
| `serverUrl` | `string` | non | URL CalDAV Zimbra |
|
||||
| `username` | `string` | non | Compte Zimbra |
|
||||
| `encryptedPassword` | `string` | non | Mot de passe chiffré via `TokenEncryptor` (même pattern que `GiteaConfiguration`) |
|
||||
| `calendarPath` | `string` | non | Chemin complet du calendrier, ex: `/dav/user@domain.com/Calendar/` |
|
||||
| `enabled` | `bool` | non | Activer/désactiver la sync (default false) |
|
||||
|
||||
## Service CalDAV
|
||||
|
||||
### `CalDavService`
|
||||
|
||||
Dépendances : `sabre/vobject` pour la génération ICS, requêtes HTTP via `Symfony\Contracts\HttpClient`.
|
||||
|
||||
Le service utilise la `ZimbraConfiguration` pour construire l'URL CalDAV complète : `{serverUrl}{calendarPath}{uid}.ics`. Le mot de passe est déchiffré via `TokenEncryptor` avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.
|
||||
|
||||
#### Méthodes
|
||||
|
||||
- `createEvent(Task): string` — crée un VEVENT (créneau planifié), retourne l'UID
|
||||
- `createTodo(Task): string` — crée un VTODO (deadline), retourne l'UID
|
||||
- `updateEvent(Task): void` — met à jour le VEVENT existant
|
||||
- `updateTodo(Task): void` — met à jour le VTODO existant
|
||||
- `deleteEvent(string $uid): void` — supprime le VEVENT par UID
|
||||
- `deleteTodo(string $uid): void` — supprime le VTODO par UID
|
||||
- `testConnection(): bool` — teste la connexion CalDAV
|
||||
|
||||
#### Format ICS
|
||||
|
||||
Toutes les dates sont envoyées en **UTC** (suffixe `Z`). Les composants sont wrappés dans un document iCalendar complet :
|
||||
|
||||
**VEVENT (créneau planifié)** :
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Lesstime//CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{calendarEventUid}
|
||||
SUMMARY:[PROJET-NUM] Titre de la tâche
|
||||
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
|
||||
DTEND:{scheduledEnd en UTC}
|
||||
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||
RRULE:{rrule si récurrence}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
**VTODO (deadline)** :
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Lesstime//CalDAV//EN
|
||||
BEGIN:VTODO
|
||||
UID:{calendarTodoUid}
|
||||
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
|
||||
DUE:{deadline en UTC}
|
||||
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.
|
||||
|
||||
## Logique de sync
|
||||
|
||||
### Déclenchement
|
||||
|
||||
Un **API Platform State Processor** (`TaskCalendarProcessor`) qui décore le persist/remove processor. La sync CalDAV est appelée **après** le flush en BDD, jamais pendant la transaction. Cela garantit :
|
||||
- La tâche est sauvegardée même si Zimbra est down
|
||||
- Pas de blocage de transaction DB par les appels HTTP
|
||||
|
||||
Pour les **MCP tools**, le `CalDavService` doit être appelé explicitement après le `flush()` dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).
|
||||
|
||||
### Matrice d'actions
|
||||
|
||||
| Action Lesstime | Effet CalDAV |
|
||||
|---|---|
|
||||
| Tâche créée/modifiée avec `syncToCalendar=true` et dates renseignées | Crée ou met à jour VEVENT + VTODO |
|
||||
| `syncToCalendar` décoché | Supprime VEVENT + VTODO si existants |
|
||||
| Tâche supprimée | Supprime VEVENT + VTODO si existants |
|
||||
| Tâche récurrente passe en `isFinal` | Tâche archivée (`archived=true`), événements **conservés** dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent |
|
||||
| Dates retirées | Supprime les events correspondants |
|
||||
|
||||
### Gestion des erreurs
|
||||
|
||||
- Timeout CalDAV : 5 secondes
|
||||
- En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
|
||||
- L'erreur est persistée dans `calendarSyncError` (visible dans l'UI comme indicateur rouge)
|
||||
- Les UIDs CalDAV restent `null` si la création a échoué
|
||||
- En cas de succès après un échec précédent, `calendarSyncError` est remis à `null`
|
||||
|
||||
## Tâches récurrentes
|
||||
|
||||
### Comportement
|
||||
|
||||
1. L'utilisateur crée une tâche avec récurrence dans Lesstime
|
||||
2. **Zimbra** : un seul VEVENT avec `RRULE` est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
|
||||
3. **Lesstime** : une seule tâche existe à la fois
|
||||
4. Quand la tâche passe en statut `isFinal` :
|
||||
- La tâche est archivée automatiquement (`archived = true`)
|
||||
- Les événements Zimbra sont **conservés** (historique)
|
||||
- Les `calendarEventUid` et `calendarTodoUid` de la tâche archivée sont **vidés** (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée
|
||||
- Une nouvelle tâche est créée avec :
|
||||
- Même titre, description, assigné, tags, projet, groupe, effort, priorité
|
||||
- Nouveau `number` généré via `findMaxNumberByProjectForUpdate` (même pattern transactionnel que `TaskNumberProcessor`)
|
||||
- Statut réinitialisé au premier statut (position la plus basse)
|
||||
- Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
|
||||
- `calendarEventUid` pointant vers le même VEVENT récurrent
|
||||
- Nouveau `calendarTodoUid` (nouvelle deadline)
|
||||
- `occurrenceCount` incrémenté sur `TaskRecurrence` (avec lock optimiste `@ORM\Version` pour éviter les doublons en cas de concurrence)
|
||||
5. Si `maxOccurrences` ou `endDate` atteint, la récurrence s'arrête (pas de nouvelle tâche créée)
|
||||
|
||||
### Calcul de la prochaine date
|
||||
|
||||
La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :
|
||||
|
||||
- **Daily** : `scheduledStart + interval jours`
|
||||
- **Weekly** : prochain jour de `daysOfWeek` à partir de `scheduledStart + interval semaines`
|
||||
- **Monthly** : même `dayOfMonth` ou même `weekOfMonth`+jour, mois `+ interval`
|
||||
- **Yearly** : même date, année `+ interval`
|
||||
|
||||
La durée du créneau (`scheduledEnd - scheduledStart`) est conservée.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Onglet "Planification" dans TaskModal
|
||||
|
||||
La modale tâche existante aura 2 onglets :
|
||||
|
||||
**Onglet "Détails"** (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe
|
||||
|
||||
**Onglet "Planification"** (nouveau) :
|
||||
|
||||
#### Bloc Dates
|
||||
- Date planifiée début (`datetime-local` picker)
|
||||
- Date planifiée fin (`datetime-local` picker)
|
||||
- Deadline (`date` picker)
|
||||
|
||||
#### Bloc Calendrier
|
||||
- Checkbox "Envoyer au calendrier" (décoché par défaut)
|
||||
- Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)
|
||||
|
||||
#### Bloc Récurrence
|
||||
- Toggle "Tâche récurrente"
|
||||
- Si activé :
|
||||
- Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
|
||||
- Intervalle : "Tous les X ..." (input number)
|
||||
- Conditionnel selon le type :
|
||||
- Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
|
||||
- Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
|
||||
- Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)
|
||||
|
||||
### Affichage des dates
|
||||
|
||||
**Cartes Kanban (`TaskCard`)** :
|
||||
- Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
|
||||
- Icône calendrier si `syncToCalendar` activé
|
||||
- Icône récurrence si tâche récurrente
|
||||
|
||||
**Vue liste (`TaskListItem`)** :
|
||||
- Colonne "Planifié" (date début)
|
||||
- Colonne "Deadline"
|
||||
- Icône récurrence si tâche récurrente
|
||||
|
||||
**Page "Mes tâches"** :
|
||||
- Même affichage que la vue liste
|
||||
- Tri possible par deadline ou date planifiée
|
||||
|
||||
### Page Admin — Configuration Zimbra
|
||||
|
||||
Nouveau bloc dans la page admin existante :
|
||||
|
||||
- URL du serveur CalDAV (input text)
|
||||
- Nom d'utilisateur (input text)
|
||||
- Mot de passe (input password)
|
||||
- Chemin du calendrier (input text)
|
||||
- Toggle activer/désactiver
|
||||
- Bouton "Tester la connexion" (toast succès/erreur)
|
||||
|
||||
Accessible uniquement `ROLE_ADMIN`.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Mise à jour des tools existants
|
||||
|
||||
`create-task` et `update-task` : nouveaux paramètres optionnels :
|
||||
- `scheduledStart` (string datetime ISO)
|
||||
- `scheduledEnd` (string datetime ISO)
|
||||
- `deadline` (string datetime ISO)
|
||||
- `syncToCalendar` (bool)
|
||||
|
||||
### Nouveaux tools
|
||||
|
||||
- `create-task-recurrence` — paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?
|
||||
- `update-task-recurrence` — paramètres : recurrenceId, + mêmes champs optionnels
|
||||
- `delete-task-recurrence` — paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant
|
||||
|
||||
## API Filters
|
||||
|
||||
Ajouter sur `Task` les filtres API Platform suivants :
|
||||
- `DateFilter` sur `scheduledStart`, `scheduledEnd`, `deadline` (pour le tri et filtrage par plage de dates)
|
||||
- `BooleanFilter` sur `syncToCalendar`
|
||||
- `OrderFilter` sur `scheduledStart`, `deadline`
|
||||
|
||||
### Valeurs stockées en JSON (i18n)
|
||||
|
||||
Les `daysOfWeek` dans `TaskRecurrence` sont stockés en anglais (`monday`, `tuesday`...) — les labels traduits sont gérés uniquement côté frontend via i18n.
|
||||
|
||||
## Dépendances PHP
|
||||
|
||||
- `sabre/vobject` — génération/parsing ICS (VEVENT, VTODO, RRULE)
|
||||
- `symfony/http-client` — requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
|
||||
- Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.
|
||||
248
frontend/assets/css/dark.css
Normal file
248
frontend/assets/css/dark.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Dark theme overrides
|
||||
* Automatically applied when <html class="dark"> is set.
|
||||
* Overrides existing Tailwind utilities so components need zero changes.
|
||||
*/
|
||||
|
||||
/* ── Backgrounds ── */
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1e1f2b !important;
|
||||
}
|
||||
|
||||
.dark .bg-tertiary-500 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-50 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-100 {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-200 {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
/* ── Hover backgrounds ── */
|
||||
|
||||
.dark .hover\:bg-neutral-50:hover {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-100:hover {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-200:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-300:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* ── Text ── */
|
||||
|
||||
.dark .text-neutral-900 {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-800 {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-700 {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-600 {
|
||||
color: #8b8b9a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-500 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-400 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-300 {
|
||||
color: #52525b !important;
|
||||
}
|
||||
|
||||
/* ── Hover text ── */
|
||||
|
||||
.dark .hover\:text-neutral-700:hover {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-neutral-600:hover {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* ── Borders ── */
|
||||
|
||||
.dark .border-neutral-200 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-100 {
|
||||
border-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-300 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-300:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-400:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
/* ── Ring ── */
|
||||
|
||||
.dark .ring-black\/5 {
|
||||
--tw-ring-color: rgb(255 255 255 / 0.05) !important;
|
||||
}
|
||||
|
||||
/* ── Specific component overrides ── */
|
||||
|
||||
/* Modal header bg */
|
||||
.dark .bg-neutral-50\/80 {
|
||||
background-color: rgb(38 40 56 / 0.8) !important;
|
||||
}
|
||||
|
||||
/* Sidebar collapse button */
|
||||
.dark .shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* User dropdown */
|
||||
.dark .shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* Forms: inputs, selects, textareas */
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]),
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
background-color: #1e1f2b !important;
|
||||
color: #e5e5e5 !important;
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"])::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.dark textarea:focus,
|
||||
.dark select:focus {
|
||||
border-color: #222783 !important;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.dark label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── Malio Layer UI components ── */
|
||||
|
||||
/* MalioSelect: floating label has hardcoded background: white */
|
||||
.dark .floating-label {
|
||||
background: #1e1f2b !important;
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: text-black used for selected value and options */
|
||||
.dark .text-black {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/60 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/40 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-black used when option is selected */
|
||||
.dark .border-black {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-m-muted default border */
|
||||
.dark .border-m-muted {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown option hover background */
|
||||
.dark .bg-m-muted\/10 {
|
||||
background-color: rgb(160 174 192 / 0.15) !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown shadow */
|
||||
.dark .shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
/* Checkbox/radio hardcoded black borders */
|
||||
.dark .inp-cbx + .cbx svg {
|
||||
stroke: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .inp-cbx + .cbx {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* Red/colored backgrounds for buttons */
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-red-100:hover {
|
||||
background-color: rgb(127 29 29 / 0.3) !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* Datetime/date input color-scheme for dark mode */
|
||||
.dark input[type="datetime-local"],
|
||||
.dark input[type="date"],
|
||||
.dark input[type="time"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1e1f2b;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3a3d54;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4d64;
|
||||
}
|
||||
@@ -10,15 +10,13 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<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.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
|
||||
@@ -79,7 +79,16 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-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">
|
||||
@@ -216,7 +225,7 @@ const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
@@ -261,36 +270,29 @@ const detailTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
const current = statusTarget.value.status
|
||||
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
||||
{ label: t('clientTicket.status.new'), value: 'new' },
|
||||
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
||||
{ label: t('clientTicket.status.done'), value: 'done' },
|
||||
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
||||
]
|
||||
// Filter out forbidden transitions
|
||||
return allStatuses.filter(s => {
|
||||
if (s.value === current) return false
|
||||
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
||||
return true
|
||||
})
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const match = iri.match(/\/api\/projects\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
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 match = iri.match(/\/api\/users\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
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
|
||||
|
||||
124
frontend/components/admin/AdminZimbraTab.vue
Normal file
124
frontend/components/admin/AdminZimbraTab.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('zimbra.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.serverUrl"
|
||||
:label="$t('zimbra.settings.serverUrl')"
|
||||
:placeholder="$t('zimbra.settings.serverUrlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="$t('zimbra.settings.username')"
|
||||
:placeholder="$t('zimbra.settings.usernamePlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.calendarPath"
|
||||
:label="$t('zimbra.settings.calendarPath')"
|
||||
:placeholder="$t('zimbra.settings.calendarPathPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||
</label>
|
||||
<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('zimbra.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('zimbra.settings.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useZimbraService } from '~/services/zimbra'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||
|
||||
const form = reactive({
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
calendarPath: '',
|
||||
password: '',
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const hasPassword = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.serverUrl = settings.serverUrl ?? ''
|
||||
form.username = settings.username ?? ''
|
||||
form.calendarPath = settings.calendarPath ?? ''
|
||||
form.enabled = settings.enabled
|
||||
hasPassword.value = settings.hasPassword
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
serverUrl: form.serverUrl.trim() || null,
|
||||
username: form.username.trim() || null,
|
||||
calendarPath: form.calendarPath.trim() || null,
|
||||
password: form.password || null,
|
||||
enabled: form.enabled,
|
||||
})
|
||||
hasPassword.value = result.hasPassword
|
||||
form.password = ''
|
||||
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>
|
||||
@@ -27,90 +27,163 @@
|
||||
{{ $t('portal.ticketDetail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
<div 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">
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="5"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentList
|
||||
v-if="localDocuments.length"
|
||||
:documents="localDocuments"
|
||||
:is-admin="false"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
<!-- View mode -->
|
||||
<template v-else>
|
||||
<!-- Title -->
|
||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||
|
||||
<!-- Document preview -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<TaskDocumentUpload
|
||||
v-if="ticket"
|
||||
:client-ticket-id="ticket.id"
|
||||
@uploaded="refreshDocuments"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Date -->
|
||||
<p class="mt-6 text-xs text-neutral-400">
|
||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
<!-- 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>
|
||||
@@ -119,9 +192,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
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
|
||||
@@ -130,6 +204,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
@@ -138,12 +213,82 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
function close() {
|
||||
isEditing.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const { getByTicket } = useTaskDocumentService()
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
:disabled="isSubmitting"
|
||||
@@ -73,7 +73,21 @@
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="project.taskCount === 0"
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +118,7 @@ const isOpen = computed({
|
||||
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useProjectService()
|
||||
const { create, update, remove } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
@@ -213,6 +228,19 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.project.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
confirmDeleteOpen.value = false
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
|
||||
@@ -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
|
||||
|
||||
131
frontend/components/task/TaskBulkActions.vue
Normal file
131
frontend/components/task/TaskBulkActions.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click="emit('bulk-delete')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
</script>
|
||||
@@ -9,7 +9,17 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
@@ -44,13 +54,35 @@
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.assignee"
|
||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||
:title="task.assignee.username"
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="14"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<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"
|
||||
@@ -64,9 +96,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
showProjectColor?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
@@ -88,6 +123,18 @@ function onPlay() {
|
||||
timerStore.startFromTask(props.task)
|
||||
}
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||
|
||||
@@ -28,12 +28,13 @@
|
||||
<!-- 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">{{ formatSize(doc.size) }}</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)"
|
||||
>
|
||||
@@ -47,6 +48,7 @@
|
||||
<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[]
|
||||
@@ -72,9 +74,4 @@ function getIconForMime(mimeType: string): string {
|
||||
return 'heroicons:paper-clip'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<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">{{ formatSize(document.size) }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
@@ -77,6 +77,7 @@
|
||||
<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
|
||||
@@ -98,12 +99,6 @@ const downloadUrl = computed(() => props.document ? getDownloadUrl(props.documen
|
||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
// Focus overlay for keyboard events
|
||||
watch(() => props.document, (doc) => {
|
||||
if (doc) {
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.statusId"
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
task: Task | null
|
||||
projectId: number
|
||||
statuses: TaskStatus[]
|
||||
efforts: TaskEffort[]
|
||||
priorities: TaskPriority[]
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
statusId: null as number | null,
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
const status = props.statuses.find(s => s.id === props.task?.status?.id)
|
||||
return !!status?.isFinal
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.task?.archived
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
form.description = task.description ?? ''
|
||||
form.statusId = task.status?.id ?? null
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.statusId = null
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
populateForm(props.task)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.task, (task) => {
|
||||
if (props.modelValue) {
|
||||
populateForm(task)
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.task.id)
|
||||
confirmDeleteOpen.value = false
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.task) return
|
||||
const timerStore = useTimerStore()
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
143
frontend/components/task/TaskListItem.vue
Normal file
143
frontend/components/task/TaskListItem.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status + deadline/calendar/recurrence -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="13"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="13"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
@@ -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
|
||||
@@ -56,6 +56,25 @@
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in ['details', 'planning']"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab as 'details' | 'planning'"
|
||||
>
|
||||
{{ $t(`tasks.${tab}Tab`) }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'details'">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -65,6 +84,20 @@
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
|
||||
<!-- Project select (create mode with project list) -->
|
||||
<div v-if="showProjectSelect" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.projectId"
|
||||
:options="projectOptions"
|
||||
label="Projet *"
|
||||
empty-option-label="Sélectionner un projet"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||
Le projet est requis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-column selects -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<MalioSelect
|
||||
@@ -102,6 +135,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 -->
|
||||
@@ -134,7 +175,10 @@
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +190,7 @@
|
||||
/>
|
||||
<TaskDocumentList
|
||||
v-if="isEditing && task"
|
||||
:documents="documents"
|
||||
:documents="localDocuments"
|
||||
:is-admin="isAdmin"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
@@ -156,7 +200,7 @@
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < documents.length - 1"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
@@ -174,6 +218,199 @@
|
||||
v-if="hasBookStack && isEditing && task"
|
||||
:task-id="task.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'planning'" class="space-y-6">
|
||||
<!-- Dates section -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledStart') }}</label>
|
||||
<input
|
||||
v-model="form.scheduledStart"
|
||||
type="datetime-local"
|
||||
class="w-full rounded-md 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>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledEnd') }}</label>
|
||||
<input
|
||||
v-model="form.scheduledEnd"
|
||||
type="datetime-local"
|
||||
class="w-full rounded-md 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>
|
||||
<div class="mt-4">
|
||||
<div class="sm:w-1/2">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.deadline') }}</label>
|
||||
<input
|
||||
v-model="form.deadline"
|
||||
type="date"
|
||||
class="w-full rounded-md 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>
|
||||
</div>
|
||||
|
||||
<!-- Calendar sync -->
|
||||
<div class="rounded-lg border border-neutral-200 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="form.syncToCalendar"
|
||||
type="checkbox"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
|
||||
</label>
|
||||
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
|
||||
<Icon
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="18"
|
||||
/>
|
||||
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
|
||||
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="rounded-lg border border-neutral-200 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="form.isRecurring"
|
||||
type="checkbox"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="form.isRecurring" class="mt-4 space-y-4">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
|
||||
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
|
||||
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
|
||||
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
|
||||
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Interval -->
|
||||
<MalioInputText
|
||||
v-model="form.recurrenceInterval"
|
||||
:label="$t('tasks.planning.interval')"
|
||||
type="number"
|
||||
input-class="w-full sm:w-1/3"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
|
||||
<!-- Weekly: days of week -->
|
||||
<div v-if="form.recurrenceType === 'weekly'">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="day in weekDays"
|
||||
:key="day.value"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.recurrenceDaysOfWeek.includes(day.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="day.value"
|
||||
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
|
||||
@change="toggleDay(day.value)"
|
||||
/>
|
||||
{{ day.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly options -->
|
||||
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
|
||||
{{ $t('tasks.planning.dayOfMonth') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
|
||||
{{ $t('tasks.planning.weekOfMonth') }}
|
||||
</label>
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-if="form.monthlyMode === 'dayOfMonth'"
|
||||
v-model="form.recurrenceDayOfMonth"
|
||||
:label="$t('tasks.planning.dayOfMonthLabel')"
|
||||
type="number"
|
||||
input-class="w-full sm:w-1/3"
|
||||
min="1"
|
||||
max="31"
|
||||
/>
|
||||
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
|
||||
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||
<option :value="1">1er</option>
|
||||
<option :value="2">2ème</option>
|
||||
<option :value="3">3ème</option>
|
||||
<option :value="4">4ème</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
|
||||
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
||||
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End of recurrence -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.recurrenceEnd" value="never" type="radio" />
|
||||
{{ $t('tasks.planning.neverEnds') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
|
||||
{{ $t('tasks.planning.afterOccurrences') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-if="form.recurrenceEnd === 'occurrences'"
|
||||
v-model="form.recurrenceMaxOccurrences"
|
||||
type="number"
|
||||
input-class="w-20"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="form.recurrenceEnd" value="date" type="radio" />
|
||||
{{ $t('tasks.planning.onDate') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-if="form.recurrenceEnd === 'date'"
|
||||
v-model="form.recurrenceEndDate"
|
||||
type="date"
|
||||
input-class="w-44"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
@@ -245,8 +482,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
@@ -255,6 +494,9 @@ import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -266,6 +508,7 @@ const props = defineProps<{
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
projects?: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -279,12 +522,14 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const activeTab = ref<'details' | 'planning'>('details')
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -306,10 +551,28 @@ 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,
|
||||
scheduledStart: '',
|
||||
scheduledEnd: '',
|
||||
deadline: '',
|
||||
syncToCalendar: false,
|
||||
isRecurring: false,
|
||||
recurrenceType: 'daily' as string,
|
||||
recurrenceInterval: '1',
|
||||
recurrenceDaysOfWeek: [] as string[],
|
||||
recurrenceDayOfMonth: '',
|
||||
monthlyMode: 'dayOfMonth' as string,
|
||||
recurrenceWeekOfMonth: 1,
|
||||
recurrenceWeekDay: 'monday' as string,
|
||||
recurrenceEnd: 'never' as string,
|
||||
recurrenceMaxOccurrences: '',
|
||||
recurrenceEndDate: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
project: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
@@ -328,8 +591,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.filter(g => !g.archived)
|
||||
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(() => {
|
||||
@@ -352,6 +629,22 @@ function toggleTag(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const weekDays = computed(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
|
||||
{ value: 'thursday', label: t('tasks.planning.days.thu') },
|
||||
{ value: 'friday', label: t('tasks.planning.days.fri') },
|
||||
{ value: 'saturday', label: t('tasks.planning.days.sat') },
|
||||
{ value: 'sunday', label: t('tasks.planning.days.sun') },
|
||||
])
|
||||
|
||||
function toggleDay(day: string) {
|
||||
const idx = form.recurrenceDaysOfWeek.indexOf(day)
|
||||
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
|
||||
else form.recurrenceDaysOfWeek.push(day)
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
@@ -362,6 +655,43 @@ 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
|
||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||
form.syncToCalendar = task.syncToCalendar ?? false
|
||||
|
||||
if (task.recurrence) {
|
||||
form.isRecurring = true
|
||||
form.recurrenceType = task.recurrence.type
|
||||
form.recurrenceInterval = String(task.recurrence.interval)
|
||||
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
|
||||
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
|
||||
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
|
||||
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
|
||||
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
|
||||
if (task.recurrence.maxOccurrences) {
|
||||
form.recurrenceEnd = 'occurrences'
|
||||
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
|
||||
} else if (task.recurrence.endDate) {
|
||||
form.recurrenceEnd = 'date'
|
||||
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
|
||||
} else {
|
||||
form.recurrenceEnd = 'never'
|
||||
}
|
||||
} else {
|
||||
form.isRecurring = false
|
||||
form.recurrenceType = 'daily'
|
||||
form.recurrenceInterval = '1'
|
||||
form.recurrenceDaysOfWeek = []
|
||||
form.recurrenceDayOfMonth = ''
|
||||
form.monthlyMode = 'dayOfMonth'
|
||||
form.recurrenceWeekOfMonth = 1
|
||||
form.recurrenceWeekDay = 'monday'
|
||||
form.recurrenceEnd = 'never'
|
||||
form.recurrenceMaxOccurrences = ''
|
||||
form.recurrenceEndDate = ''
|
||||
}
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
@@ -371,13 +701,52 @@ function populateForm(task: Task | null) {
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
form.scheduledStart = ''
|
||||
form.scheduledEnd = ''
|
||||
form.deadline = ''
|
||||
form.syncToCalendar = false
|
||||
form.isRecurring = false
|
||||
form.recurrenceType = 'daily'
|
||||
form.recurrenceInterval = '1'
|
||||
form.recurrenceDaysOfWeek = []
|
||||
form.recurrenceDayOfMonth = ''
|
||||
form.monthlyMode = 'dayOfMonth'
|
||||
form.recurrenceWeekOfMonth = 1
|
||||
form.recurrenceWeekDay = 'monday'
|
||||
form.recurrenceEnd = 'never'
|
||||
form.recurrenceMaxOccurrences = ''
|
||||
form.recurrenceEndDate = ''
|
||||
}
|
||||
touched.title = false
|
||||
touched.project = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
activeTab.value = 'details'
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -387,21 +756,33 @@ watch(() => props.task, (task) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
try {
|
||||
const settings = await getGiteaSettings()
|
||||
giteaUrl.value = settings.url ?? ''
|
||||
} catch {
|
||||
// Gitea not available
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const clientTickets = ref<ClientTicket[]>([])
|
||||
const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// Reset group and reload client tickets when project changes in create mode
|
||||
watch(() => form.projectId, async (pid) => {
|
||||
if (!showProjectSelect.value) return
|
||||
form.groupId = null
|
||||
form.clientTicketId = null
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
@@ -416,7 +797,6 @@ function ticketStatusClass(status: string): string {
|
||||
}
|
||||
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const documents = computed(() => localDocuments.value)
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
// Sync documents from task prop when modal opens or task changes
|
||||
@@ -431,7 +811,7 @@ async function refreshDocuments() {
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
@@ -440,13 +820,13 @@ function openPreview(doc: TaskDocument) {
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = documents.value[previewIndex.value - 1]
|
||||
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < documents.value.length - 1) {
|
||||
previewDoc.value = documents.value[previewIndex.value + 1]
|
||||
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +869,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()
|
||||
}
|
||||
@@ -518,7 +898,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 {
|
||||
@@ -530,14 +912,45 @@ 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,
|
||||
scheduledStart: form.scheduledStart || null,
|
||||
scheduledEnd: form.scheduledEnd || null,
|
||||
deadline: form.deadline || null,
|
||||
syncToCalendar: form.syncToCalendar,
|
||||
}
|
||||
|
||||
let savedTask: Task
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
savedTask = await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
savedTask = await create(payload)
|
||||
}
|
||||
|
||||
// Handle recurrence
|
||||
if (form.isRecurring) {
|
||||
const recPayload = {
|
||||
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
|
||||
interval: parseInt(form.recurrenceInterval) || 1,
|
||||
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
|
||||
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
|
||||
? parseInt(form.recurrenceDayOfMonth) || null : null,
|
||||
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
|
||||
? form.recurrenceWeekOfMonth : null,
|
||||
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
|
||||
maxOccurrences: form.recurrenceEnd === 'occurrences'
|
||||
? parseInt(form.recurrenceMaxOccurrences) || null : null,
|
||||
}
|
||||
|
||||
if (savedTask.recurrence) {
|
||||
await updateRecurrence(savedTask.recurrence.id, recPayload)
|
||||
} else {
|
||||
const recurrence = await createRecurrence(recPayload)
|
||||
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
|
||||
}
|
||||
} else if (isEditing.value && props.task?.recurrence) {
|
||||
await removeRecurrence(props.task.recurrence.id)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="blockEl"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||
:style="blockStyle"
|
||||
:class="{ 'opacity-40': isDragSource }"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@@ -17,38 +17,39 @@
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
|
||||
<div class="px-1.5 py-0.5 h-full overflow-hidden">
|
||||
<!-- 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>
|
||||
<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>
|
||||
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
|
||||
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
||||
<!-- Top: title + project -->
|
||||
<div class="min-w-0">
|
||||
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom: tags left, duration right -->
|
||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Medium: title + duration -->
|
||||
<template v-else-if="sizeLevel === 2">
|
||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</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>
|
||||
</template>
|
||||
|
||||
<!-- Tiny: just a colored bar, no text -->
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle bottom (outside block) -->
|
||||
@@ -116,10 +117,22 @@ const sizeLevel = computed(() => {
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||
const bgColor = props.entry.project?.color ?? '#94a3b8'
|
||||
|
||||
const col = props.columnIndex ?? 0
|
||||
const total = props.totalColumns ?? 1
|
||||
@@ -127,13 +140,28 @@ const blockStyle = computed(() => {
|
||||
const leftPercent = (col / total) * 100
|
||||
const widthPercent = (1 / total) * 100
|
||||
|
||||
return {
|
||||
const base: Record<string, string> = {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx.value}px`,
|
||||
backgroundColor: bgColor,
|
||||
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||
}
|
||||
|
||||
if (hasProject.value) {
|
||||
const hex = props.entry.project!.color.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
||||
base.color = `rgb(${r}, ${g}, ${b})`
|
||||
} else {
|
||||
base.backgroundColor = '#e5e7eb'
|
||||
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
||||
base.backgroundSize = '12px 12px'
|
||||
base.color = '#6b7280'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Click / Drag detection ---
|
||||
|
||||
@@ -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>
|
||||
@@ -105,19 +105,29 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } 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'
|
||||
@@ -231,6 +241,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
@@ -257,7 +287,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,13 +1,13 @@
|
||||
<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
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
@click="emit('editEntry', entry)"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
@@ -18,14 +18,14 @@
|
||||
|
||||
<!-- Main info -->
|
||||
<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' }}
|
||||
</span>
|
||||
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
@@ -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,27 +1,27 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
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
|
||||
v-for="day in days"
|
||||
:key="day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
<!-- Grid body with sticky header -->
|
||||
<div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Day headers (sticky inside scroll container) -->
|
||||
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
</div>
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid body -->
|
||||
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Columns -->
|
||||
<div class="relative flex">
|
||||
<!-- Hour labels -->
|
||||
<div class="w-16 shrink-0">
|
||||
<div
|
||||
@@ -99,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>
|
||||
@@ -134,13 +134,16 @@
|
||||
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end columns flex -->
|
||||
</div><!-- end gridBodyEl -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
startDate: Date
|
||||
@@ -198,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
|
||||
// Scroll to current hour on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!calendarEl.value) return
|
||||
const scrollParent = getScrollParent()
|
||||
if (!scrollParent) return
|
||||
if (!gridBodyEl.value) return
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const calendarTop = calendarEl.value.offsetTop
|
||||
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
||||
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
||||
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -459,7 +459,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,5 +1,5 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
|
||||
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||
<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"
|
||||
@@ -7,17 +7,38 @@
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
|
||||
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
@click="toggleTitle"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
|
||||
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
@click="ui.toggleDarkMode()"
|
||||
>
|
||||
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||
<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"
|
||||
@@ -43,7 +64,14 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
||||
|
||||
function toggleTitle() {
|
||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
||||
localStorage.setItem('appTitle', appTitle.value)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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="cancel" />
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
|
||||
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<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">{{ $t('projects.deleteConfirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('projects.deleteConfirmMessage') }}
|
||||
</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')"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</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>
|
||||
|
||||
174
frontend/components/ui/DateFilter.vue
Normal file
174
frontend/components/ui/DateFilter.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
|
||||
<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>
|
||||
<button
|
||||
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
|
||||
>
|
||||
<Icon name="mdi:calendar-blank" size="20" />
|
||||
</button>
|
||||
</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
|
||||
pickerMode?: 'day' | 'week'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Date | [Date, Date] | null]
|
||||
}>()
|
||||
|
||||
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
|
||||
const mode = computed(() => props.pickerMode ?? '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 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() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
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)
|
||||
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,11 +1,11 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
|
||||
class="flex items-center justify-center gap-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'
|
||||
collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
|
||||
]"
|
||||
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||
|
||||
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"
|
||||
@@ -90,6 +90,8 @@ 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
|
||||
item: UserData | null
|
||||
@@ -114,14 +116,14 @@ const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: 'Aucun client', value: null as number | null },
|
||||
{ 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 !== null && p.client.id === form.clientId,
|
||||
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -146,6 +148,13 @@ function onClientChange(value: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.roles, (roles) => {
|
||||
if (!roles.includes('ROLE_CLIENT')) {
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
@@ -187,10 +196,12 @@ async function handleSubmit() {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
|
||||
allowedProjects: form.clientId !== null
|
||||
? 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 }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
|
||||
export function useClientTicketHelpers() {
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
@@ -25,5 +27,22 @@ export function useClientTicketHelpers() {
|
||||
})
|
||||
}
|
||||
|
||||
return { typeBadgeClass, statusBadgeClass, formatDate }
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -22,43 +22,69 @@
|
||||
"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.",
|
||||
"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"
|
||||
"hideArchived": "Masquer les projets archivés",
|
||||
"noProjects": "Aucun projet trouvé.",
|
||||
"noArchivedProjects": "Aucun projet archivé.",
|
||||
"addProject": "Ajouter un projet",
|
||||
"addProjectShort": "Projet",
|
||||
"editProject": "Modifier un projet",
|
||||
"deleteConfirmTitle": "Supprimer le projet",
|
||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
|
||||
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
|
||||
},
|
||||
"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",
|
||||
@@ -78,17 +104,64 @@
|
||||
"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",
|
||||
"detailsTab": "Détails",
|
||||
"planningTab": "Planification",
|
||||
"planning": {
|
||||
"dates": "Dates",
|
||||
"scheduledStart": "Début planifié",
|
||||
"scheduledEnd": "Fin planifiée",
|
||||
"deadline": "Deadline",
|
||||
"calendar": "Calendrier",
|
||||
"syncToCalendar": "Envoyer au calendrier Zimbra",
|
||||
"syncOk": "Synchronisé",
|
||||
"recurrence": "Récurrence",
|
||||
"isRecurring": "Tâche récurrente",
|
||||
"type": "Type",
|
||||
"daily": "Quotidien",
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel",
|
||||
"interval": "Intervalle",
|
||||
"daysOfWeek": "Jours de la semaine",
|
||||
"days": {
|
||||
"mon": "Lu",
|
||||
"tue": "Ma",
|
||||
"wed": "Me",
|
||||
"thu": "Je",
|
||||
"fri": "Ve",
|
||||
"sat": "Sa",
|
||||
"sun": "Di"
|
||||
},
|
||||
"dayOfMonth": "Jour du mois",
|
||||
"dayOfMonthLabel": "Jour (1-31)",
|
||||
"weekOfMonth": "Semaine du mois",
|
||||
"weekOfMonthLabel": "Semaine",
|
||||
"dayLabel": "Jour",
|
||||
"endRecurrence": "Fin de la récurrence",
|
||||
"neverEnds": "Jamais",
|
||||
"afterOccurrences": "Après X occurrences",
|
||||
"occurrences": "Occurrences",
|
||||
"onDate": "À une date",
|
||||
"endDate": "Date de fin"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -112,7 +185,12 @@
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
"backlog": "Backlog",
|
||||
"createTask": "Créer une tâche",
|
||||
"sortBy": "Trier par",
|
||||
"sortDefault": "Par défaut",
|
||||
"sortDeadline": "Échéance",
|
||||
"sortScheduledStart": "Date planifiée"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -166,7 +244,20 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -229,6 +320,7 @@
|
||||
"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",
|
||||
@@ -282,6 +374,12 @@
|
||||
"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",
|
||||
@@ -304,5 +402,35 @@
|
||||
"noResults": "Aucun résultat",
|
||||
"empty": "Aucun document lié"
|
||||
}
|
||||
},
|
||||
"zimbra": {
|
||||
"settings": {
|
||||
"title": "Calendrier Zimbra",
|
||||
"serverUrl": "URL du serveur CalDAV",
|
||||
"serverUrlPlaceholder": "https://mail.ovh.com",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernamePlaceholder": "user{'@'}domain.com",
|
||||
"calendarPath": "Chemin du calendrier",
|
||||
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
|
||||
"password": "Mot de passe",
|
||||
"passwordConfigured": "Mot de passe configuré",
|
||||
"enabled": "Activer la synchronisation CalDAV",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration Zimbra enregistrée",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie",
|
||||
"testFailed": "Connexion échouée"
|
||||
}
|
||||
},
|
||||
"taskRecurrence": {
|
||||
"created": "Récurrence créée",
|
||||
"updated": "Récurrence mise à jour",
|
||||
"deleted": "Récurrence supprimée"
|
||||
},
|
||||
"recurrence": {
|
||||
"daily": "Quotidien",
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,3 @@
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { version } = useAppVersion()
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
|
||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||
<img
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
@@ -26,9 +26,9 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/malio.png"
|
||||
src="/LOGO_CARRE.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-cover object-left"
|
||||
class="w-[46px] h-[55px]"
|
||||
/>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@@ -86,11 +86,18 @@
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||
icon="mdi:ticket-outline"
|
||||
label="Tickets client"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
icon="mdi:clock-outline"
|
||||
icon="mdi:calendar-edit-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
@@ -108,24 +115,26 @@
|
||||
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<button
|
||||
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()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="20"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
||||
<button
|
||||
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="18"
|
||||
/>
|
||||
</button>
|
||||
</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 flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
@@ -148,6 +157,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()
|
||||
@@ -211,9 +221,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)
|
||||
@@ -242,11 +252,6 @@ function onCompleteSaved() {
|
||||
timerStore.clearPendingEntry()
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
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('/')
|
||||
}
|
||||
})
|
||||
@@ -10,17 +10,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
const isClientOnly = auth.isAuthenticated
|
||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
// ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages
|
||||
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
const isPortalRoute = to.path.startsWith('/portal')
|
||||
const isLoginRoute = to.path === '/login'
|
||||
if (!isPortalRoute && !isLoginRoute) {
|
||||
return navigateTo('/portal')
|
||||
}
|
||||
const isProfileRoute = to.path === '/profile'
|
||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||
return navigateTo('/portal')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
css: ['~/assets/css/dark.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
@@ -23,14 +24,6 @@ export default defineNuxtConfig({
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
],
|
||||
@@ -62,5 +55,8 @@ export default defineNuxtConfig({
|
||||
},
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
build: {
|
||||
transpile: ['@vuepic/vue-datepicker']
|
||||
}
|
||||
})
|
||||
|
||||
178
frontend/package-lock.json
generated
178
frontend/package-lock.json
generated
@@ -12,11 +12,13 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/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"
|
||||
}
|
||||
@@ -541,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",
|
||||
@@ -1094,6 +1102,68 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
|
||||
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -5259,6 +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",
|
||||
@@ -5720,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",
|
||||
@@ -6658,6 +6790,12 @@
|
||||
"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",
|
||||
@@ -7126,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",
|
||||
@@ -7160,6 +7308,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -7437,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",
|
||||
@@ -13728,6 +13888,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-advanced-cropper": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.6",
|
||||
"debounce": "^1.2.0",
|
||||
"easy-bem": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-bundle-renderer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/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"
|
||||
}
|
||||
|
||||
@@ -27,14 +27,15 @@
|
||||
<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'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const tabs = [
|
||||
@@ -44,9 +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' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -471,7 +471,7 @@ const lineOptions = {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => `${formatHours(ctx.raw)}`,
|
||||
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -480,7 +480,7 @@ const lineOptions = {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f3f4f6' },
|
||||
ticks: {
|
||||
callback: (value: any) => `${value}h`,
|
||||
callback: (value: number | string) => `${value}h`,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -48,7 +48,6 @@ useHead({
|
||||
title: 'Connexion'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
|
||||
@@ -56,7 +55,7 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
@@ -64,7 +63,7 @@ const handleSubmit = async () => {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await router.push(isClient ? '/portal' : '/')
|
||||
await navigateTo(isClient ? '/portal' : '/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
@@ -48,9 +50,16 @@ const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||
const sortBy = ref<SortOption>('default')
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
@@ -152,6 +161,11 @@ async function loadTasks() {
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortBy.value === 'deadline') {
|
||||
params['order[deadline]'] = 'asc'
|
||||
} else if (sortBy.value === 'scheduledStart') {
|
||||
params['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
@@ -164,9 +178,9 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters to reload tasks
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
@@ -214,42 +228,118 @@ async function onDropBacklog(event: DragEvent) {
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
if (task.project?.code && task.number) {
|
||||
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskModalOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam) {
|
||||
const dashIndex = taskParam.lastIndexOf('-')
|
||||
if (dashIndex > 0) {
|
||||
const code = taskParam.slice(0, dashIndex)
|
||||
const num = Number(taskParam.slice(dashIndex + 1))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<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 gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<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'"
|
||||
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:view-column-outline" size="20" />
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</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'"
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="20" />
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,16 +399,27 @@ onMounted(() => {
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@@ -326,24 +427,27 @@ onMounted(() => {
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ 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 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"
|
||||
show-project-color
|
||||
@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>
|
||||
@@ -363,6 +467,7 @@ onMounted(() => {
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
@@ -376,57 +481,31 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="tasks.length"
|
||||
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
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"
|
||||
:task="task"
|
||||
show-project-color
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@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 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"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<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>
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
@@ -446,6 +525,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>
|
||||
|
||||
@@ -53,10 +53,8 @@ const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
// Extract project ID from IRI
|
||||
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
|
||||
if (match) {
|
||||
const projectId = Number(match[1])
|
||||
const projectId = extractIdFromIri(ticket.project)
|
||||
if (projectId) {
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
@@ -68,13 +66,12 @@ async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
// Admin sees all projects
|
||||
const allProjects = await projectService.getAll({ archived: false })
|
||||
projects.value = allProjects
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
} else {
|
||||
// Client sees allowed projects
|
||||
projects.value = auth.user?.allowedProjects ?? []
|
||||
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||
}
|
||||
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -30,34 +30,56 @@
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<!-- Kanban board -->
|
||||
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
class="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm transition hover:shadow-md"
|
||||
@click="openDetail(ticket)"
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<div class="mb-3 flex shrink-0 items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||
{{ col.tickets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div
|
||||
v-for="ticket in col.tickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
|
||||
:draggable="isAdmin"
|
||||
@dragstart="onDragStart(ticket)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ formatDate(ticket.createdAt) }}
|
||||
<p
|
||||
v-if="col.tickets.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,13 +87,49 @@
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="selectedTicket"
|
||||
@refresh="loadTickets"
|
||||
/>
|
||||
|
||||
<!-- Reject comment modal -->
|
||||
<Teleport v-if="rejectModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
|
||||
<textarea
|
||||
v-model="rejectComment"
|
||||
rows="3"
|
||||
class="mt-3 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
:placeholder="$t('clientTicket.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
@click="cancelReject"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
>
|
||||
{{ $t('clientTicket.status.rejected') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
@@ -84,40 +142,143 @@ const projectId = computed(() => Number(route.params.id))
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projectName = ref('')
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const projectName = computed(() => {
|
||||
const me = auth.user as any
|
||||
if (me?.allowedProjects) {
|
||||
const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
|
||||
return project?.name ?? ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false)
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-500'
|
||||
case 'in_progress': return 'bg-yellow-500'
|
||||
case 'done': return 'bg-green-500'
|
||||
case 'rejected': return 'bg-red-500'
|
||||
default: return 'bg-neutral-400'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => allStatuses.map(status => ({
|
||||
status,
|
||||
label: t(`clientTicket.status.${status}`),
|
||||
dotClass: statusDotClass(status),
|
||||
tickets: tickets.value.filter(tk => tk.status === status),
|
||||
})))
|
||||
|
||||
// Drag & drop (admin only)
|
||||
const draggedTicket = ref<ClientTicket | null>(null)
|
||||
const dragOverStatus = ref<ClientTicketStatus | null>(null)
|
||||
|
||||
function onDragStart(ticket: ClientTicket) {
|
||||
draggedTicket.value = ticket
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTicket.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: ClientTicketStatus) {
|
||||
if (!draggedTicket.value) return
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(newStatus: ClientTicketStatus) {
|
||||
dragOverStatus.value = null
|
||||
const ticket = draggedTicket.value
|
||||
draggedTicket.value = null
|
||||
|
||||
if (!ticket || ticket.status === newStatus) return
|
||||
|
||||
// Rejected requires a comment
|
||||
if (newStatus === 'rejected') {
|
||||
pendingRejectTicket.value = ticket
|
||||
rejectComment.value = ''
|
||||
rejectModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = newStatus
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const rejectModalOpen = ref(false)
|
||||
const rejectComment = ref('')
|
||||
const pendingRejectTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
function cancelReject() {
|
||||
rejectModalOpen.value = false
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const ticket = pendingRejectTicket.value
|
||||
if (!ticket || !rejectComment.value.trim()) return
|
||||
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = 'rejected'
|
||||
rejectModalOpen.value = false
|
||||
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, {
|
||||
status: 'rejected',
|
||||
statusComment: rejectComment.value.trim(),
|
||||
})
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
const [ticketList, project] = await Promise.all([
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
projectService.getById(projectId.value),
|
||||
])
|
||||
tickets.value = ticketList
|
||||
projectName.value = project.name
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTickets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
101
frontend/pages/profile.vue
Normal file
101
frontend/pages/profile.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||
<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>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const removing = ref(false)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
selectedFile.value = file
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function onCrop(blob: Blob) {
|
||||
selectedFile.value = null
|
||||
if (!auth.user) return
|
||||
|
||||
try {
|
||||
await upload(auth.user.id, blob)
|
||||
await auth.refreshUser()
|
||||
} catch {
|
||||
// Upload error — $fetch will throw on non-2xx
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
if (!auth.user) return
|
||||
removing.value = true
|
||||
|
||||
try {
|
||||
await remove(auth.user.id)
|
||||
await auth.refreshUser()
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -46,13 +46,11 @@
|
||||
>
|
||||
{{ task.group.title }}
|
||||
</span>
|
||||
<span
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||
:title="task.assignee.username"
|
||||
>
|
||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||
</span>
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +128,7 @@ const filteredTasks = computed(() => {
|
||||
async function loadData() {
|
||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProjectArchived(projectId.value),
|
||||
taskService.getByProject(projectId.value, true),
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
|
||||
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
Tickets client
|
||||
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option :value="null">Tous les statuts</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>
|
||||
|
||||
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap 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">{{ 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.5 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.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click.stop="onDelete(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 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>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="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>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Tickets client' })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(ticket: ClientTicket) {
|
||||
await clientTicketService.remove(ticket.id)
|
||||
await loadTickets()
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
])
|
||||
project.value = p
|
||||
tickets.value = t
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
title="Vue liste"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
|
||||
title="Paramètres du projet"
|
||||
@click="projectDrawerOpen = true"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
@@ -49,15 +68,33 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@@ -65,30 +102,33 @@
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog -->
|
||||
<div
|
||||
v-if="viewMode === 'kanban'"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||
@dragover.prevent
|
||||
@@ -107,6 +147,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="filteredTasks.length"
|
||||
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(filteredTasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="filteredTasks.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
@@ -120,6 +193,13 @@
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="projectDrawerOpen"
|
||||
:project="project"
|
||||
:clients="clients"
|
||||
@saved="onProjectSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,7 +212,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -142,11 +224,13 @@ import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
@@ -163,15 +247,21 @@ const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const projectDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
@@ -190,6 +280,14 @@ const statusFilterOptions = computed(() =>
|
||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const priorityFilterOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortFilterOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
@@ -204,6 +302,12 @@ const filteredTasks = computed(() => {
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
result = result.filter(t => t.effort?.id === selectedEffortId.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -218,7 +322,7 @@ const backlogTasks = computed(() =>
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value),
|
||||
statusService.getAll(),
|
||||
@@ -227,6 +331,7 @@ async function loadData() {
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
clientService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
@@ -236,6 +341,7 @@ async function loadData() {
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -244,13 +350,23 @@ async function loadData() {
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskDrawerOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
if (project.value?.code && task.number) {
|
||||
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskDrawerOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
@@ -286,11 +402,74 @@ async function onDropBacklog(event: DragEvent) {
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
async function onProjectSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam && project.value) {
|
||||
const prefix = `${project.value.code}-`
|
||||
if (taskParam.startsWith(prefix)) {
|
||||
const num = Number(taskParam.slice(prefix.length))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
||||
@@ -18,8 +18,8 @@
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un projet</span>
|
||||
<span class="sm:hidden">+ Projet</span>
|
||||
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,8 +29,9 @@
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
|
||||
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
|
||||
:class="{ 'opacity-60': project.archived }"
|
||||
:style="projectCardStyle(project.color)"
|
||||
@click="navigateTo(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -40,7 +41,7 @@
|
||||
v-if="project.archived"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
Archivé
|
||||
{{ $t('common.archived') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -59,7 +60,7 @@
|
||||
v-if="projects.length === 0 && !isLoading"
|
||||
class="col-span-full py-12 text-center text-neutral-400"
|
||||
>
|
||||
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
|
||||
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +81,17 @@ import { useClientService } from '~/services/clients'
|
||||
|
||||
useHead({ title: 'Projets' })
|
||||
|
||||
function projectCardStyle(color: string | null) {
|
||||
const hex = (color || '#222783').replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
return {
|
||||
borderRadius: '16px',
|
||||
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
|
||||
|
||||
@@ -13,26 +13,31 @@
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200">
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="px-3 py-1 text-sm font-semibold transition"
|
||||
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
@@ -73,7 +78,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 -mb-24 min-h-0 flex-1">
|
||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||
<TimeEntryList
|
||||
v-if="viewMode === 'list'"
|
||||
:entries="filteredEntries"
|
||||
@@ -124,6 +129,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 { useTimeEntryService } from '~/services/time-entries'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
useHead({ title: 'Suivi des temps' })
|
||||
@@ -136,6 +142,7 @@ const startDate = ref(getMonday(new Date()))
|
||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||
|
||||
const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
@@ -281,24 +288,10 @@ async function onPaste() {
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updatePageHeaderHeight()
|
||||
|
||||
if (!pageHeaderEl.value || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||
updatePageHeaderHeight()
|
||||
})
|
||||
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pageHeaderResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
|
||||
async function onDelete(entry: TimeEntry) {
|
||||
await timeEntryService.remove(entry.id)
|
||||
await loadEntries()
|
||||
@@ -319,9 +312,9 @@ async function loadReferenceData() {
|
||||
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'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
@@ -330,11 +323,21 @@ async function loadReferenceData() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updatePageHeaderHeight()
|
||||
|
||||
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
|
||||
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||
updatePageHeaderHeight()
|
||||
})
|
||||
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||
}
|
||||
|
||||
await loadReferenceData()
|
||||
await loadEntries()
|
||||
})
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||
loadEntries()
|
||||
})
|
||||
@@ -342,4 +345,16 @@ watch(viewMode, () => {
|
||||
watch(selectedUserId, () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedDateFilter, (val) => {
|
||||
if (!val) return
|
||||
if (Array.isArray(val)) {
|
||||
startDate.value = getMonday(val[0])
|
||||
viewMode.value = 'week'
|
||||
} else {
|
||||
startDate.value = val
|
||||
viewMode.value = 'day'
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
BIN
frontend/public/LOGO_CARRE.png
Normal file
BIN
frontend/public/LOGO_CARRE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -1,22 +1,22 @@
|
||||
import type { UserData } from './dto/user-data'
|
||||
|
||||
export const getCurrentUser = () => {
|
||||
const api = useApi()
|
||||
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
|
||||
export function getCurrentUser() {
|
||||
const api = useApi()
|
||||
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
|
||||
}
|
||||
|
||||
export const login = (username: string, password: string) => {
|
||||
const api = useApi()
|
||||
return api.post('/login_check', { username, password }, {
|
||||
toastOn401: true,
|
||||
toastErrorKey: 'errors.auth.login'
|
||||
})
|
||||
export function login(username: string, password: string) {
|
||||
const api = useApi()
|
||||
return api.post('/login_check', { username, password }, {
|
||||
toastOn401: true,
|
||||
toastErrorKey: 'errors.auth.login'
|
||||
})
|
||||
}
|
||||
|
||||
export const logout = () => {
|
||||
const api = useApi()
|
||||
return api.post('/logout', {}, {
|
||||
toastErrorKey: 'errors.auth.logout',
|
||||
toastSuccessKey: 'success.auth.logout'
|
||||
})
|
||||
export function logout() {
|
||||
const api = useApi()
|
||||
return api.post('/logout', {}, {
|
||||
toastErrorKey: 'errors.auth.logout',
|
||||
toastSuccessKey: 'success.auth.logout'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,11 +30,17 @@ export function useClientTicketService() {
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, updateStatus, remove }
|
||||
return { getAll, getById, create, update, updateStatus, remove }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type Project = {
|
||||
bookstackShelfId: number | null
|
||||
bookstackShelfName: string | null
|
||||
archived: boolean
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
|
||||
22
frontend/services/dto/task-recurrence.ts
Normal file
22
frontend/services/dto/task-recurrence.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TaskRecurrence = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
}
|
||||
|
||||
export type TaskRecurrenceWrite = {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek?: string[] | null
|
||||
dayOfMonth?: number | null
|
||||
weekOfMonth?: number | null
|
||||
endDate?: string | null
|
||||
maxOccurrences?: number | null
|
||||
}
|
||||
@@ -29,6 +29,23 @@ export type Task = {
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
syncToCalendar: boolean
|
||||
calendarSyncError: string | null
|
||||
recurrence: {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type TaskWrite = {
|
||||
@@ -42,4 +59,10 @@ export type TaskWrite = {
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
clientTicket?: string | null
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
syncToCalendar?: boolean
|
||||
recurrence?: string | null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user