Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions
2e36e06966 chore: bump version to v0.2.5
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m24s
2026-03-17 09:36:25 +00:00
Matthieu
fb6a1931f5 chore : bump version to 0.25 and fix MCP session directory
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Move MCP session storage from cache dir to var/mcp-sessions
so it survives cache:clear operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:35:00 +01:00
205 changed files with 2336 additions and 11418 deletions

View File

@@ -1,224 +0,0 @@
---
name: push-tickets-lesstime
description: Use after full-project-review to push TICKETS.md tickets into Lesstime project management via MCP. Triggers on "push tickets", "envoyer tickets", "creer les tickets dans lesstime", "sync tickets lesstime", "pousser les tickets".
---
# Push Tickets to Lesstime
## Overview
Prend le fichier `TICKETS.md` genere par le skill `full-project-review` et cree les taches correspondantes dans Lesstime via son MCP server. Chaque ticket devient une tache avec la bonne priorite, le bon groupe, et la description complete.
## When to Use
- Apres un `full-project-review` qui a genere un `TICKETS.md`
- L'utilisateur demande de "pousser", "sync", "envoyer" les tickets dans Lesstime
- L'utilisateur veut creer les taches dans son gestionnaire de projet
## Prerequis
- Un fichier `TICKETS.md` doit exister dans le repertoire courant (genere par `full-project-review`)
- L'API Lesstime doit etre accessible via HTTP
## Connexion a Lesstime
Lesstime est accessible via un serveur MCP HTTP (JSON-RPC 2.0). Il n'y a PAS de MCP natif configure dans Claude Code — il faut appeler l'API directement via `curl` dans le Bash tool.
### Parametres de connexion
```
URL: http://project.malio-dev.fr/_mcp
TOKEN: 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64
```
### Procedure de connexion (3 etapes)
**Etape 1 — Initialiser la session** (SANS header Mcp-Session-Id) :
```bash
curl -s -D /tmp/mcp_headers -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}' > /dev/null
```
**Etape 2 — Extraire le Session ID** depuis les headers de reponse :
```bash
SID=$(grep -i "mcp-session-id" /tmp/mcp_headers | awk '{print $2}' | tr -d '\r\n')
```
**Etape 3 — Appeler les outils** avec le Session ID :
```bash
curl -s -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}'
```
Les reponses sont au format `{"jsonrpc":"2.0","id":X,"result":{"content":[{"type":"text","text":"[JSON_DATA]"}]}}`.
Extraire les donnees avec : `python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(json.loads(d['result']['content'][0]['text']))"`
### Approche recommandee : script Python
Pour pousser plusieurs tickets, generer un script Python temporaire qui :
1. Initialise la session via curl subprocess
2. Extrait le SID
3. Boucle sur les tickets et appelle create-task pour chacun
4. Affiche le resultat
Voir la memoire `reference_lesstime.md` pour les IDs connus (projets, users, statuts, priorites).
### IDs frequemment utilises
| Type | Label | ID |
|------|-------|----|
| Statut | A faire | 1 |
| Statut | En cours | 2 |
| Statut | Termine | 5 |
| Priorite | Basse | 1 |
| Priorite | Moyen | 2 |
| Priorite | Haute | 3 |
| User | matteo | 6 |
| User | Matthieu | 5 |
| Projet | Infrastructure | 13 |
| Projet | Lesstime | 5 |
| Projet | Inventory | 7 |
| Projet | Ferme | 8 |
| Projet | SIRH | 12 |
**IMPORTANT :** Toujours faire un appel `list-projects` / `list-users` / `list-priorities` en phase Discovery pour verifier que les IDs sont toujours valides. Les IDs ci-dessus sont un cache pour aller plus vite, pas une source de verite.
## Outils MCP Lesstime disponibles
Le MCP Lesstime expose 22 outils. Voici ceux utilises par ce skill :
### Discovery (appeler en premier pour mapper les IDs)
| Outil | Usage |
|-------|-------|
| `list-projects` | Trouver le projectId cible |
| `list-statuses` | Recuperer les statuts disponibles (label, id, color) |
| `list-priorities` | Recuperer les priorites disponibles (label, id, color) |
| `list-efforts` | Recuperer les niveaux d'effort (label, id) |
| `list-groups` | Lister les groupes d'un projet (par projectId) |
| `list-tags` | Lister les tags disponibles (label, id, color) |
| `list-users` | Lister les utilisateurs pour l'assignation |
### Creation
| Outil | Usage |
|-------|-------|
| `create-task` | Creer une tache (projectId, title, description, statusId, priorityId, effortId, assigneeId, groupId, tagIds) |
| `create-group` | Creer un groupe dans un projet (projectId, title) |
### Parametres de `create-task`
```
projectId: int (required) -- ID du projet cible
title: string (required) -- Titre du ticket (ex: "T-001 -- Supprimer le webhook hardcode")
description: string (optional) -- Corps complet du ticket (Pourquoi + A faire + Fichiers)
statusId: int (optional) -- ID du statut initial
priorityId: int (optional) -- ID de la priorite
effortId: int (optional) -- ID de l'effort estime
assigneeId: int (optional) -- ID de l'utilisateur assigne
groupId: int (optional) -- ID du groupe (utilise pour regrouper par priorite)
tagIds: int[] (optional) -- IDs des tags
```
## Process
```dot
digraph push_flow {
rankdir=TB;
"1. Lire TICKETS.md" -> "2. Discovery MCP (parallele)";
"2. Discovery MCP (parallele)" -> "3. Demander projet cible";
"3. Demander projet cible" -> "4. Mapper priorites";
"4. Mapper priorites" -> "5. Creer groupes si besoin";
"5. Creer groupes si besoin" -> "6. Creer les taches";
"6. Creer les taches" -> "7. Resume au user";
}
```
### Phase 1 -- Lire et parser TICKETS.md
Lire le fichier `TICKETS.md` du repertoire courant. Extraire :
- La liste des tickets avec leur ID (T-001, T-002, ...)
- Le titre de chaque ticket
- La priorite (P0, P1, P2, P3) -- derivee de la section dans laquelle se trouve le ticket
- Le corps complet (Pourquoi + A faire + Fichiers) -- sera la description de la tache
**Parsing :**
- Les sections `## P0`, `## P1`, `## P2`, `## P3` delimitent les groupes de priorite
- Chaque `### T-XXX -- {Titre}` est un ticket
- Tout le contenu entre deux `### T-XXX` constitue la description du ticket
### Phase 2 -- Discovery MCP (appels paralleles)
Appeler ces outils MCP **en parallele** pour recuperer les metadonnees :
1. `list-projects` -- pour afficher les projets disponibles
2. `list-statuses` -- pour mapper le statut initial des taches
3. `list-priorities` -- pour mapper P0/P1/P2/P3 aux priorites Lesstime
4. `list-efforts` -- pour estimer l'effort
5. `list-tags` -- pour les tags disponibles
### Phase 3 -- Demander le projet cible
Presenter a l'utilisateur la liste des projets Lesstime et lui demander :
1. **Quel projet ?** -- dans quel projet creer les taches
2. **Quel statut initial ?** -- ex: "To Do", "Backlog"
3. **Creer des groupes par priorite ?** -- ex: "P0 - Urgents", "P1 - Importants"
4. **Assigner a quelqu'un ?** -- optionnel
5. **Tags a ajouter ?** -- ex: "review", "tech-debt"
### Phase 4 -- Mapper les priorites
Mapper les priorites du TICKETS.md aux priorites Lesstime :
- P0 -> priorite la plus haute disponible (ex: "Urgent", "Critical")
- P1 -> priorite haute (ex: "High")
- P2 -> priorite moyenne (ex: "Medium")
- P3 -> priorite basse (ex: "Low")
Si le mapping n'est pas evident, demander confirmation a l'utilisateur.
### Phase 5 -- Creer les groupes (si demande)
Si l'utilisateur veut des groupes par priorite :
1. Creer le groupe "P0 - Urgents (securite)" via `create-group`
2. Creer le groupe "P1 - Importants" via `create-group`
3. Creer le groupe "P2 - Documentation" via `create-group`
4. Creer le groupe "P3 - Nice to have" via `create-group`
### Phase 6 -- Creer les taches
Pour chaque ticket dans TICKETS.md :
1. Construire le titre : `"T-XXX -- {titre}"`
2. Construire la description : le corps complet du ticket (Pourquoi + A faire + Fichiers)
3. Appeler `create-task` avec tous les parametres mappes
**Optimisation :** Creer les taches en parallele par batch de 5 pour eviter de surcharger l'API.
### Phase 7 -- Resume
Afficher un resume au user :
- Nombre de taches creees
- Repartition par priorite
- Lien vers le projet Lesstime (si disponible)
- Taches echouees (si applicable) avec raison
## Mapping par defaut
| TICKETS.md | Lesstime Priority | Lesstime Group |
|------------|-------------------|----------------|
| P0 | Urgent/Critical | "P0 - Urgents (securite)" |
| P1 | High | "P1 - Importants" |
| P2 | Medium | "P2 - Documentation" |
| P3 | Low | "P3 - Nice to have" |
## Common Mistakes
- **Oublier la phase Discovery** -- les IDs de priorites/statuts varient par workspace Lesstime
- **Ne pas demander confirmation** -- toujours valider le projet cible et le mapping avant de creer
- **Creer sans groupes** -- les groupes rendent la vue Lesstime beaucoup plus lisible
- **Description trop courte** -- inclure le corps complet du ticket, pas juste le titre
- **Ne pas gerer les erreurs** -- si une tache echoue, continuer avec les suivantes et reporter a la fin

View File

@@ -1,61 +0,0 @@
# Ticket Executor - Learnings
## Session 2026-03-17 (26 tickets)
### T-001 — Secrets .env
- **Pattern**: Replace secrets with `change_me_in_env_local` placeholder, move real values to `.env.local`
- **Gotcha**: `.env.local` must contain ALL overridden secrets
### T-002 — Security API Gitea
- **Pattern**: Ajouter `security: "is_granted('ROLE_USER')"` sur les opérations ApiResource
- **Learning**: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer
### T-003 — SVG Upload
- **Pattern**: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
- **Learning**: Toujours vérifier upload ET download controllers
### T-004 — MCP create-task / Repos numérotation
- **Gotcha critique**: PostgreSQL n'autorise PAS `FOR UPDATE` avec des fonctions d'agrégation (`MAX`)
- **Fix**: Utiliser `pg_advisory_xact_lock()` au lieu de `FOR UPDATE` pour les queries avec agrégation
- **Pattern**: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket
### T-005 — Filter ROLE_CLIENT projects
- **Pattern**: Créer une Doctrine Extension (`QueryCollectionExtensionInterface` + `QueryItemExtensionInterface`) pour filtrer par relation
- **Learning**: Symfony autoconfigure enregistre l'extension automatiquement
### T-006 — Block client doc upload
- **Pattern**: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
- **Learning**: Le portail client envoie un `clientTicket` IRI (pas de `task` IRI), donc le check sur `taskIri` non-vide suffit
### T-007 — MCP role checks
- **Pattern**: Injecter `Security` dans chaque Tool, vérifier au début de `__invoke()`
- **Learning**: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)
### T-009 — Password hashing
- **Pattern**: Champ `plainPassword` non-persisté, writable uniquement, hashé dans le Processor
- **Learning**: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API
### T-010 — Rate limiting
- **Gotcha**: `login_throttling` nécessite `symfony/rate-limiter` installé, pas juste dans composer.json
- **Learning**: Toujours vérifier que les packages sont installés, pas juste déclarés
### T-012 — Harmoniser repos numérotation
- **Pattern**: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
- **Learning**: Vérifier TOUS les appelants d'une méthode renommée
### T-015 — useAvatarService
- **Learning**: Quand on migre vers `useApi()`, ajouter la détection FormData pour ne pas écraser le Content-Type multipart
### T-020 — i18n
- **Pattern**: Ajouter `useI18n()` dans le setup script avant de pouvoir utiliser `t()` dans le JS
- **Learning**: Les templates peuvent utiliser `$t()` directement sans import
### T-022 — Retirer twig-bundle
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min

View File

@@ -1,78 +0,0 @@
---
name: ticket-executor
description: Execute Lesstime project tickets systematically - updates MCP statuses, follows project conventions, and logs learnings for self-improvement
---
# Ticket Executor Skill
## Purpose
Execute Lesstime project tickets end-to-end: read the ticket, implement the fix, update MCP status, and log learnings.
## Workflow
### 1. Receive Ticket
- Get ticket ID, title, description, tags (Backend/Frontend), priority, and current status
- Understand the scope from the title and description
### 2. Set Status to "En cours" (ID: 2)
- Use MCP `update-task` with `statusId: 2` before starting work
- MCP endpoint: `http://project.malio-dev.fr/_mcp`
- Auth: `Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64`
### 3. Analyze & Implement
Based on tag:
- **Backend**: Check `src/Entity/`, `src/State/`, `src/Controller/`, `src/Security/`, `config/`
- **Frontend**: Check `frontend/components/`, `frontend/composables/`, `frontend/pages/`, `frontend/services/`
Conventions to follow:
- PHP: `declare(strict_types=1)`, Symfony + PSR-12, API Platform patterns
- Frontend: TypeScript strict, `useApi()` composable, 4 spaces indent
- See CLAUDE.md for full conventions
### 4. Verify
- For Backend: `make php-cs-fixer-allow-risky` if PHP changed
- For Frontend: check TypeScript types, no `any`
- Read modified files to confirm correctness
### 5. Set Status to "Terminé" (ID: 5)
- Use MCP `update-task` with `statusId: 5` after successful implementation
### 6. Log Learnings
Append to `.claude/skills/ticket-executor/LEARNINGS.md`:
- What worked well
- Patterns discovered
- Gotchas encountered
- Time-saving shortcuts found
## MCP Session Management
The MCP HTTP transport requires a session. To call tools:
```bash
# Initialize session (get Mcp-Session-Id from response header)
curl -si -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}'
# Call tool (use Mcp-Session-Id from init response)
curl -s -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update-task","arguments":{"id":<taskId>,"statusId":<statusId>}}}'
```
## Status IDs
- 1 = A faire
- 2 = En cours
- 3 = Bloqué
- 4 = En attente de validation
- 5 = Terminé
## Learnings Integration
Before each ticket, read `LEARNINGS.md` to apply previous insights.
After each ticket, append new learnings. This creates a feedback loop that improves execution quality over time.
## Parallel Execution Rules
- Independent tickets (no shared files) can run in parallel via worktree agents
- Tickets modifying the same files must run sequentially
- Always verify no merge conflicts after parallel execution

View File

@@ -1,24 +0,0 @@
.git
.gitea
.env.local
.env.test
infra/dev/
infra/prod/docker-compose.yml
infra/prod/deploy.sh
infra/prod/deploy-release.sh
infra/prod/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
vendor/
LOG/
docs/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

6
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev
APP_SECRET="change_me_in_env_local"
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
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=change_me_in_env_local
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
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=change_me_in_env_local
ENCRYPTION_KEY=aaaaaaaaa

View File

@@ -1,99 +0,0 @@
###############################################################################
# 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 infra/dev/.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 (infra/dev/.env.docker)
#
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
# pour les valeurs par defaut. Creez infra/dev/.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

View File

@@ -1,30 +0,0 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/lesstime:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }}
docker push gitea.malio.fr/malio-dev/lesstime:latest

View File

@@ -0,0 +1,66 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
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 \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/lesstime-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

8
.gitignore vendored
View File

@@ -22,11 +22,3 @@
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> ide ###
.idea/
###< ide ###
###> docker local ###
infra/dev/.env.docker.local
###< docker local ###

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 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 Normal file
View File

@@ -0,0 +1,8 @@
<?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 Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

10
.idea/material_theme_project_new.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?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 Normal file
View File

@@ -0,0 +1,8 @@
<?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 Normal file
View File

@@ -0,0 +1,20 @@
<?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 Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -1,22 +1,8 @@
{
"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"
]
}
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
}
}
}

View File

@@ -12,11 +12,10 @@ 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, 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/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument)
src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
src/Service/ # Services métier (NotificationService)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
@@ -31,10 +30,10 @@ docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4
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/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
frontend/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
```
@@ -69,13 +68,6 @@ 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
@@ -105,7 +97,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
### MCP Server
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
@@ -125,7 +117,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Container PHP : `php-lesstime-fpm`
- Container Nginx : `nginx-lesstime`
- Container DB : PostgreSQL sur port **5435** (interne et externe)
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime`
## Fixtures
@@ -134,5 +126,3 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- 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)

View File

View File

@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données |
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
## API

View File

@@ -16,9 +16,7 @@
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",
@@ -31,10 +29,10 @@
"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.*"
},
@@ -93,6 +91,8 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0"
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
}
}

1435
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,11 @@ 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],

View File

@@ -1,5 +1,5 @@
api_platform:
title: Lesstime API
title: Hello API Platform
version: 1.0.0
formats:
jsonld: ['application/ld+json']

View File

@@ -22,9 +22,6 @@ 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

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: true
* enabled?: bool|Param, // Default: false
* 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,6 +685,38 @@ 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"
@@ -1259,8 +1291,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: false
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true
* 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
@@ -1609,154 +1641,12 @@ 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,
@@ -1764,12 +1654,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,
@@ -1777,13 +1667,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,
@@ -1791,13 +1681,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,
@@ -1805,7 +1695,6 @@ 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,

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.28'
app.version: '0.2.5'

View 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;
}
}

View File

@@ -1,364 +0,0 @@
# Deploiement Docker — Lesstime
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer Lesstime.
Creer la base de donnees pour Lesstime :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE lesstime_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne Lesstime sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/lesstime
sudo chown -R $(whoami):$(whoami) /var/www/lesstime
cd /var/www/lesstime
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
container_name: lesstime-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export LESSTIME_IMAGE_TAG="$TAG"
echo "==> Deploying lesstime:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
sudo docker compose pull
echo "==> Starting container..."
sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
# App
DEFAULT_URI=https://project.malio-dev.fr
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/lesstime.conf /etc/nginx/sites-enabled/lesstime.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp lesstime.sql user@serveur:/tmp/lesstime.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio lesstime_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio lesstime_prod < /tmp/lesstime.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/lesstime
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/lesstime.sql
```
### Structure finale du dossier
```
/var/www/lesstime/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
├── public/
│ └── maintenance.html # extrait automatiquement par deploy.sh
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/lesstime
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.3.12
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.3.12
```
---
## CI/CD
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
1. Build l'image multi-stage
2. Push vers `gitea.malio.fr/malio-dev/lesstime:<tag>` et `:latest`
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
---
## Voir les logs
```bash
cd /var/www/lesstime
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (bare-metal)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/lesstime-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/lesstime/.env /var/www/lesstime-docker/.env
cp -a /var/www/lesstime/config/jwt /var/www/lesstime-docker/config/jwt
cp -a /var/www/lesstime/var/uploads /var/www/lesstime-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/lesstime-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/lesstime-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/lesstime-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/lesstime-docker /var/www/lesstime`

View File

@@ -1,153 +0,0 @@
# Configuration du mode maintenance (nginx hote)
Guide pour activer le support du mode maintenance pilote par Central.
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
## Ce qui a ete fait pour Lesstime
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/lesstime
sudo ./deploy.sh
```
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
```
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 4. Verifier
- Depuis Central, activer la maintenance sur Lesstime
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
- Desactiver la maintenance depuis Central → le site revient
---
## A faire pour Inventory
Meme procedure :
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/inventory
sudo ./deploy.sh
```
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
> ```bash
> mkdir -p public
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
> ```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name inventory.malio-dev.fr;
root /var/www/inventory/public;
# Maintenance mode
if (-f /var/www/inventory/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
---
## Fonctionnement
```
Central (container)
└── touch /var/www/maintenance/lesstime/maintenance.on
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
/var/www/lesstime/maintenance.on (hote)
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
maintenance.html servie depuis /var/www/lesstime/public/
```

View File

@@ -2,7 +2,7 @@ services:
php:
container_name: php-${DOCKER_APP_NAME}-fpm
build:
context: ./infra/dev
context: ./docker/php
dockerfile: Dockerfile
args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -21,8 +21,8 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads
extra_hosts:
@@ -41,7 +41,7 @@ services:
- "8082:80"
volumes:
- ./:/var/www/html:ro
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/lesstime.conf:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
restart: unless-stopped
db:
image: postgres:16-alpine

9
docker/.env.docker.local Normal file
View File

@@ -0,0 +1,9 @@
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

View File

@@ -1,87 +0,0 @@
# Règle Claude : Time Tracking automatique via Lesstime
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
---
## Time Tracking obligatoire
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
### Déclencheurs
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
### Méthode
Créer la time entry via **curl** sur l'API REST Lesstime :
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
- Body : `{"username":"admin","password":"admin"}`
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
- Body :
```json
{
"user": "/api/users/5",
"startedAt": "<ISO8601 avec timezone>",
"title": "<description courte de la tâche>",
"project": "/api/projects/<projectId>",
"tags": ["/api/task_tags/<tagId>"],
"task": "/api/tasks/<taskId>"
}
```
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
- Body : `{"stoppedAt": "<ISO8601>"}`
### Paramètres obligatoires
- **user** : TOUJOURS `/api/users/5` (Matthieu)
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
- **title** : description courte de la tâche en cours
- **project** : selon le projet (voir mapping ci-dessous)
### Tags (choisir selon le type de travail)
| Tag | ID | IRI |
|-----|----|-----|
| Backend | 3 | `/api/task_tags/3` |
| Frontend | 2 | `/api/task_tags/2` |
| IA | 7 | `/api/task_tags/7` |
| Infra | 5 | `/api/task_tags/5` |
| UI/UX | 4 | `/api/task_tags/4` |
| Maintenance | 6 | `/api/task_tags/6` |
| RDV | 1 | `/api/task_tags/1` |
| Réunion | 8 | `/api/task_tags/8` |
| Formation | 10 | `/api/task_tags/10` |
| Gestion projet | 9 | `/api/task_tags/9` |
### Mapping projets
| Projet | ID | IRI |
|--------|----|-----|
| Lesstime | 5 | `/api/projects/5` |
| Inventory | 7 | `/api/projects/7` |
| SIRH | 12 | `/api/projects/12` |
| Infrastructure | 13 | `/api/projects/13` |
| Malio UI | 11 | `/api/projects/11` |
| ERP Liot | 6 | `/api/projects/6` |
| Ferme | 8 | `/api/projects/8` |
| ADMIN | 16 | `/api/projects/16` |
| Maintenance-LIOT | 17 | `/api/projects/17` |
| Qualiopi | 14 | `/api/projects/14` |
| Vaultwarden | 18 | `/api/projects/18` |
### Règles
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
- **Toujours stopper le timer** en fin de tâche ou sur demande
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
- **Choisir les tags intelligemment** selon le type de travail effectué

View File

@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
## 4. Installer le script de deploy
```bash
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime
```
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
## 7. Configurer Nginx
```bash
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
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
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,777 +0,0 @@
# Time Entry XLSX Export — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add XLSX export of time tracking data with detail + summary sheets for CIR/JEI tax documents.
**Architecture:** Custom Symfony controller generates XLSX via PhpSpreadsheet, returns BinaryFileResponse. Frontend adds an export button on time-tracking page that triggers download with current filters.
**Tech Stack:** PHP 8.4, Symfony 8.0, PhpSpreadsheet, Nuxt 4 / Vue 3
**Spec:** `docs/superpowers/specs/2026-03-24-time-entry-export-design.md`
---
### Task 1: Install PhpSpreadsheet
**Files:**
- Modify: `composer.json`
- [ ] **Step 1: Install the dependency**
```bash
docker exec -t php-lesstime-fpm composer require phpoffice/phpspreadsheet
```
- [ ] **Step 2: Verify installation**
```bash
docker exec -t php-lesstime-fpm php -r "require 'vendor/autoload.php'; new \PhpOffice\PhpSpreadsheet\Spreadsheet(); echo 'OK';"
```
Expected: `OK`
- [ ] **Step 3: Commit**
```bash
git add composer.json composer.lock
git commit -m "chore : add phpoffice/phpspreadsheet dependency for time entry export"
```
---
### Task 2: Add repository method for filtered time entries
**Files:**
- Modify: `src/Repository/TimeEntryRepository.php`
- [ ] **Step 1: Add `findForExport` method**
Add this method to `TimeEntryRepository`:
```php
/**
* @param int[]|null $tagIds
* @return TimeEntry[]
*/
public function findForExport(
\DateTimeImmutable $after,
\DateTimeImmutable $before,
?User $user = null,
?Project $project = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
->andWhere('te.startedAt >= :after')
->andWhere('te.startedAt < :before')
->setParameter('after', $after)
->setParameter('before', $before)
->orderBy('te.startedAt', 'ASC');
if (null !== $user) {
$qb->andWhere('te.user = :user')
->setParameter('user', $user);
}
if (null !== $project) {
$qb->andWhere('te.project = :project')
->setParameter('project', $project);
}
if (null !== $tagIds && [] !== $tagIds) {
$qb->join('te.tags', 'tag')
->andWhere('tag.id IN (:tagIds)')
->setParameter('tagIds', $tagIds);
}
return $qb->getQuery()->getResult();
}
```
- [ ] **Step 2: Add missing use statements if needed**
Ensure these imports are at the top of the file:
```php
use App\Entity\Project;
use App\Entity\User;
```
- [ ] **Step 3: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Repository/TimeEntryRepository.php
```
Expected: `No syntax errors detected`
- [ ] **Step 4: Commit**
```bash
git add src/Repository/TimeEntryRepository.php
git commit -m "feat : add findForExport repository method for time entries"
```
---
### Task 3: Create TimeEntryExportService
**Files:**
- Create: `src/Service/TimeEntryExportService.php`
- [ ] **Step 1: Create the service with all three sheets**
Create `src/Service/TimeEntryExportService.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\TimeEntry;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class TimeEntryExportService
{
private const array DETAIL_HEADERS = [
'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
];
private const array MONTH_NAMES = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
/**
* @param TimeEntry[] $timeEntries
*
* @return string Path to the generated temp file
*/
public function generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string
{
$spreadsheet = new Spreadsheet();
$this->buildDetailSheet($spreadsheet, $timeEntries);
$this->buildProjectRecapSheet($spreadsheet, $timeEntries);
$this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to);
$spreadsheet->setActiveSheetIndex(0);
$tempFile = tempnam(sys_get_temp_dir(), 'export_temps_') . '.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
return $tempFile;
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Détail');
// Headers
foreach (self::DETAIL_HEADERS as $col => $header) {
$colLetter = Coordinate::stringFromColumnIndex($col + 1);
$sheet->setCellValue("{$colLetter}1", $header);
}
$this->boldRow($sheet, 1, \count(self::DETAIL_HEADERS));
// Data rows
$row = 2;
foreach ($timeEntries as $entry) {
$duration = $this->computeDuration($entry);
$task = $entry->getTask();
$taskLabel = '';
if (null !== $task) {
$project = $task->getProject();
$code = $project?->getCode() ?? '';
$taskLabel = $code . '-' . $task->getNumber() . ' - ' . $task->getTitle();
}
$tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray();
$sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d'));
$sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? '');
$sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? '');
$sheet->setCellValue("D{$row}", $taskLabel);
$sheet->setCellValue("E{$row}", $entry->getTitle() ?? '');
$sheet->setCellValue("F{$row}", implode(', ', $tagLabels));
$sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i'));
$sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? '');
$sheet->setCellValue("I{$row}", round($duration, 2));
$sheet->setCellValue("J{$row}", $entry->getDescription() ?? '');
++$row;
}
// Total row
if ($row > 2) {
$sheet->setCellValue("H{$row}", 'Total');
$sheet->getStyle("H{$row}")->getFont()->setBold(true);
$sheet->setCellValue("I{$row}", "=SUM(I2:I" . ($row - 1) . ')');
$sheet->getStyle("I{$row}")->getFont()->setBold(true);
}
// Auto-size columns
foreach (range('A', 'J') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par projet');
// Aggregate: user → project → hours
$data = [];
$projects = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$projectName = $entry->getProject()?->getName() ?? 'Sans projet';
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$projects[$projectName] = true;
$data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration;
}
ksort($users);
ksort($projects);
$projectList = array_keys($projects);
$userList = array_keys($users);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($projectList as $project) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $project);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($projectList as $project) {
$val = round($data[$user][$project] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($projectList as $project) {
$projectTotal = 0;
foreach ($userList as $user) {
$projectTotal += $data[$user][$project] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
// Grand total
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par mois');
// Build month columns from the date range
$months = [];
$current = $from->modify('first day of this month');
$end = $to->modify('first day of this month');
while ($current <= $end) {
$key = $current->format('Y-m');
$label = self::MONTH_NAMES[(int) $current->format('n')] . ' ' . $current->format('Y');
$months[$key] = $label;
$current = $current->modify('+1 month');
}
// Aggregate: user → month-key → hours
$data = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$monthKey = $entry->getStartedAt()->format('Y-m');
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration;
}
ksort($users);
$userList = array_keys($users);
$monthKeys = array_keys($months);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($months as $label) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $label);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($monthKeys as $monthKey) {
$val = round($data[$user][$monthKey] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($monthKeys as $monthKey) {
$monthTotal = 0;
foreach ($userList as $user) {
$monthTotal += $data[$user][$monthKey] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
private function computeDuration(TimeEntry $entry): float
{
$start = $entry->getStartedAt();
$end = $entry->getStoppedAt();
if (null === $start || null === $end) {
return 0;
}
return ($end->getTimestamp() - $start->getTimestamp()) / 3600;
}
private function boldRow(Worksheet $sheet, int $row, int $colCount): void
{
for ($c = 1; $c <= $colCount; ++$c) {
$letter = Coordinate::stringFromColumnIndex($c);
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
}
}
}
```
- [ ] **Step 2: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Service/TimeEntryExportService.php
```
Expected: `No syntax errors detected`
- [ ] **Step 3: Commit**
```bash
git add src/Service/TimeEntryExportService.php
git commit -m "feat : add TimeEntryExportService generating XLSX with detail and recap sheets"
```
---
### Task 4: Create TimeEntryExportController
**Files:**
- Create: `src/Controller/TimeEntryExportController.php`
- [ ] **Step 1: Create the controller**
Create `src/Controller/TimeEntryExportController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): BinaryFileResponse
{
$afterStr = $request->query->getString('after');
$beforeStr = $request->query->getString('before');
if ('' === $afterStr || '' === $beforeStr) {
throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
}
try {
$after = new \DateTimeImmutable($afterStr);
$before = new \DateTimeImmutable($beforeStr);
} catch (\Exception) {
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
// Max range: 12 months
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// Authorization: non-admin users can only export their own data
$user = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $user */
$user = $this->security->getUser();
} else {
$userId = $request->query->getInt('user');
if ($userId > 0) {
$user = $this->entityManager->getRepository(User::class)->find($userId);
}
}
$project = null;
$projectId = $request->query->getInt('project');
if ($projectId > 0) {
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
}
/** @var int[] $tagIds */
$tagIds = array_filter(
array_map('intval', (array) $request->query->all('tags')),
fn (int $id) => $id > 0,
);
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$user,
$project,
$tagIds ?: null,
);
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
}
```
- [ ] **Step 2: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Controller/TimeEntryExportController.php
```
Expected: `No syntax errors detected`
- [ ] **Step 3: Clear cache and verify route is registered**
```bash
docker exec -t php-lesstime-fpm php bin/console cache:clear
docker exec -t php-lesstime-fpm php bin/console debug:router | grep time_entry_export
```
Expected: line showing `time_entry_export` route mapped to `GET /api/time_entries/export`
- [ ] **Step 4: Commit**
```bash
git add src/Controller/TimeEntryExportController.php
git commit -m "feat : add TimeEntryExportController with auth, validation, and filters"
```
---
### Task 5: Manual backend smoke test
- [ ] **Step 1: Test missing params returns 400**
```bash
docker exec -t php-lesstime-fpm php bin/console debug:router time_entry_export
```
Then via curl (using admin fixture token):
```bash
curl -s -o /dev/null -w "%{http_code}" -b "BEARER=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)" "http://localhost:8082/api/time_entries/export"
```
Expected: `400`
- [ ] **Step 2: Test valid export returns XLSX**
```bash
TOKEN=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -s -o /tmp/test-export.xlsx -w "%{http_code}" -b "BEARER=${TOKEN}" "http://localhost:8082/api/time_entries/export?after=2025-01-01&before=2026-12-31"
echo ""
file /tmp/test-export.xlsx
```
Expected: HTTP `200`, file type contains `Microsoft Excel` or `Zip archive`
- [ ] **Step 3: Commit (no changes — verification only)**
---
### Task 6: Add frontend export method and i18n
**Files:**
- Modify: `frontend/services/time-entries.ts`
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add `getExportUrl` method to time-entries service**
Add this function inside `useTimeEntryService()` before the `return` statement in `frontend/services/time-entries.ts`:
```typescript
function getExportUrl(params: {
after: string
before: string
user?: number
project?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.user) query.set('user', String(params.user))
if (params.project) query.set('project', String(params.project))
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/api/time_entries/export?${query.toString()}`
}
```
Update the return statement to include `getExportUrl`:
```typescript
return { getByDateRange, getActive, create, update, remove, getExportUrl }
```
- [ ] **Step 2: Add i18n key**
In `frontend/i18n/locales/fr.json`, add `"export": "Exporter"` inside the `"timeEntries"` object.
- [ ] **Step 3: Commit**
```bash
git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json
git commit -m "feat : add getExportUrl to time-entries service and i18n key"
```
---
### Task 7: Add export button to time-tracking page
**Files:**
- Modify: `frontend/pages/time-tracking.vue`
- [ ] **Step 1: Add export button in template**
In `frontend/pages/time-tracking.vue`, find the `<div>` containing the `MalioSelect` for tags (the last filter). After its closing `</div>`, add:
```vue
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
```
- [ ] **Step 2: Add export function in script**
Add this function in the `<script setup>` section, after the existing helper functions (near `loadEntries`):
```typescript
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
```
- [ ] **Step 3: Verify dev server compiles without errors**
```bash
cd frontend && npx nuxi typecheck
```
Expected: no errors (or only pre-existing ones)
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat : add export button to time-tracking page"
```
---
### Task 8: End-to-end manual test
- [ ] **Step 1: Start dev server and test in browser**
1. Open `http://localhost:3002/time-tracking`
2. Verify the "Exporter" button appears in the filter bar
3. Select a date range with existing time entries
4. Click "Exporter"
5. Verify an `.xlsx` file downloads
- [ ] **Step 2: Open the XLSX and verify structure**
1. Feuille "Détail" — rows with Date, Utilisateur, Projet, etc. + total row
2. Feuille "Récap par projet" — users × projects cross-table
3. Feuille "Récap par mois" — users × months cross-table
- [ ] **Step 3: Test as non-admin user**
1. Log in as `alice` / `alice`
2. Export — verify only Alice's entries appear (even if user filter was different)
- [ ] **Step 4: Run PHP CS Fixer**
```bash
make php-cs-fixer-allow-risky
```
Fix any issues, then commit if needed:
```bash
git add -A && git commit -m "style : fix code style for time entry export"
```

View File

@@ -1,278 +0,0 @@
# 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.

View File

@@ -1,144 +0,0 @@
# Export temps suivi de temps (XLSX)
**Ticket** : LST-41
**Date** : 2026-03-24
**Statut** : Approuvé
## Contexte
Les exports de suivi de temps sont nécessaires pour constituer des dossiers CIR (Crédit Impôt Recherche) et JEI (Jeune Entreprise Innovante). Ces dossiers exigent une ventilation détaillée du temps passé par collaborateur, par projet et par mois.
## Décisions
- **Format** : XLSX (via PhpSpreadsheet côté backend)
- **Déclenchement** : bouton "Exporter" sur la page time-tracking, reprenant les filtres en cours
- **Récap** : double tableau croisé (user × projet + user × mois)
## Architecture
```
Frontend Backend
───────── ───────
Bouton "Exporter"
→ GET /api/time_entries/export → TimeEntryExportController
?after=2026-01-01 → Validation params + authz
&before=2026-03-31 → TimeEntryRepository (query)
&user=5 → TimeEntryExportService (XLSX)
&project=5 → BinaryFileResponse (.xlsx)
&tags[]=2
```
## Backend
### Dépendance
`phpoffice/phpspreadsheet` ajouté via Composer.
### TimeEntryExportController
- Fichier : `src/Controller/TimeEntryExportController.php`
- Route : `GET /api/time_entries/export` avec `priority: 1`
- Sécurité : `#[IsGranted('ROLE_USER')]`
- **Autorisation** : si l'utilisateur n'a pas `ROLE_ADMIN`, le filtre `user` est forcé à l'utilisateur courant (ignore toute valeur fournie). Seuls les admins peuvent exporter les données d'autres utilisateurs ou de tous les utilisateurs.
- Paramètres query (IDs numériques, pas d'IRIs — c'est un controller custom, pas API Platform) :
- `after` (obligatoire) — date YYYY-MM-DD
- `before` (obligatoire) — date YYYY-MM-DD
- `user` (optionnel) — ID numérique (ex: `5`)
- `project` (optionnel) — ID numérique (ex: `5`)
- `tags[]` (optionnel) — tableau d'IDs numériques (ex: `tags[]=2&tags[]=3`)
- **Validation** :
- `after` et `before` obligatoires, sinon 400 Bad Request
- Plage maximale : 12 mois, sinon 400 Bad Request
- Si aucune entrée trouvée : retourne un XLSX avec en-têtes uniquement (pas d'erreur)
- Construit une query Doctrine avec ces filtres
- Appelle `TimeEntryExportService::generate()`
- Retourne `BinaryFileResponse` avec header `Content-Disposition: attachment; filename="export-temps-YYYY-MM-DD_YYYY-MM-DD.xlsx"`
### TimeEntryExportService
- Fichier : `src/Service/TimeEntryExportService.php`
- Méthode : `generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string` (retourne le chemin du fichier temp)
#### Feuille 1 — "Détail"
Toutes les entrées triées par date croissante.
| Colonne | Source | Format |
|---------|--------|--------|
| Date | `startedAt` | YYYY-MM-DD |
| Utilisateur | `user.username` | texte |
| Projet | `project.name` | texte (vide si null) |
| Tâche | `task` | "{code}-{number} - {title}" (vide si null) |
| Titre | `title` | texte |
| Tags | `tags` | labels séparés par ", " |
| Début | `startedAt` | HH:mm |
| Fin | `stoppedAt` | HH:mm (vide si null) |
| Durée (h) | calculée | nombre décimal (ex: 3.50) |
| Description | `description` | texte |
- En-têtes en gras
- Colonnes auto-dimensionnées
- Ligne de total en bas (somme de la colonne Durée)
#### Feuille 2 — "Récap par projet"
Tableau croisé dynamique :
- Lignes = utilisateurs (triés alphabétiquement)
- Colonnes = projets (triés alphabétiquement)
- Cellules = total heures (décimal)
- Dernière colonne = total par utilisateur
- Dernière ligne = total par projet
#### Feuille 3 — "Récap par mois"
Tableau croisé dynamique :
- Lignes = utilisateurs (triés alphabétiquement)
- Colonnes = mois de la période (format "Mars 2026")
- Cellules = total heures (décimal)
- Dernière colonne = total par utilisateur
- Dernière ligne = total par mois
## Frontend
### Page time-tracking
- Ajout d'un bouton "Exporter" dans la barre d'actions (à côté des filtres existants)
- Icône de téléchargement + label "Exporter"
- Au clic : construit l'URL `/api/time_entries/export` avec les filtres actuels (période affichée, user sélectionné, projet sélectionné, tags sélectionnés) et déclenche le téléchargement
### Service time-entries.ts
Ajout d'une méthode :
```typescript
function getExportUrl(params: {
after: string // YYYY-MM-DD
before: string // YYYY-MM-DD
user?: number // ID numérique
project?: number // ID numérique
tags?: number[] // tableau d'IDs
}): string
```
Construit l'URL complète avec query params. Le téléchargement est déclenché via un élément `<a>` temporaire avec attribut `download` (le cookie JWT est envoyé automatiquement sur une requête same-origin). En cas d'erreur, un toast est affiché.
### i18n
- `timeEntries.export` → "Exporter" (fr)
## Sécurité
- Accessible à `ROLE_USER` (même niveau que la consultation des time entries)
- **Non-admin : export limité à ses propres données** (filtre `user` forcé côté serveur)
- Le fichier XLSX est généré dans un fichier temporaire et supprimé après envoi
- Les filtres utilisent des IDs numériques (controller custom, pas d'IRI)
## Langue
Le contenu du XLSX est toujours en français (noms de feuilles, en-têtes de colonnes, noms de mois). C'est volontaire car les documents CIR/JEI sont des dossiers destinés à l'administration française.
## Hors scope
- Export PDF
- Export CSV
- Stockage des exports générés
- Planification d'exports automatiques

View File

@@ -1,248 +0,0 @@
/*
* 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;
}

View File

@@ -10,17 +10,21 @@
input-class="w-full"
/>
<MalioInputPassword
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
<div>
<MalioInputPassword
<MalioInputText
v-model="form.tokenSecret"
:label="$t('bookstack.settings.tokenSecret')"
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
{{ $t('bookstack.settings.tokenConfigured') }}
@@ -28,19 +32,21 @@
</div>
<div class="flex gap-3">
<MalioButton
:label="$t('bookstack.settings.save')"
button-class="w-auto px-4"
<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"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('bookstack.settings.testConnection')"
button-class="w-auto px-4"
>
{{ $t('bookstack.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
:disabled="isTesting"
@click="handleTest"
/>
>
{{ $t('bookstack.settings.testConnection') }}
</button>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un client"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un client
</button>
</div>
<DataTable

View File

@@ -92,21 +92,19 @@
<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">
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="18"
<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)"
/>
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="18"
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
<button
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
@click.stop="openDeleteConfirm(ticket)"
/>
>
<Icon name="mdi:delete-outline" size="18" />
</button>
</div>
</td>
</tr>
@@ -157,18 +155,19 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
>
{{ $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>
@@ -187,19 +186,19 @@
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="deleteModalOpen = false"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-6"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isDeleting"
@click="confirmDelete"
/>
>
Supprimer
</button>
</div>
</div>
</div>
@@ -275,22 +274,25 @@ const availableStatusTransitions = computed(() => {
})
function getProjectName(iri: string): string {
const id = extractIdFromIri(iri)
if (!id) return ''
const match = iri.match(/\/api\/projects\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
return projects.value.find(p => p.id === id)?.name ?? ''
}
function getSubmitterName(iri: string | null): string {
if (!iri) return '-'
const id = extractIdFromIri(iri)
if (!id) return ''
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
return users.value.find(u => u.id === id)?.username ?? ''
}
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const id = extractIdFromIri(iri)
if (!id) 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)
}

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un effort"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un effort
</button>
</div>
<DataTable

View File

@@ -11,10 +11,12 @@
/>
<div>
<MalioInputPassword
<MalioInputText
v-model="form.token"
:label="$t('gitea.settings.token')"
:placeholder="$t('gitea.settings.tokenPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
{{ $t('gitea.settings.tokenConfigured') }}
@@ -22,19 +24,21 @@
</div>
<div class="flex gap-3">
<MalioButton
:label="$t('gitea.settings.save')"
button-class="w-auto px-4"
<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"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('gitea.settings.testConnection')"
button-class="w-auto px-4"
>
{{ $t('gitea.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
:disabled="isTesting"
@click="handleTest"
/>
>
{{ $t('gitea.settings.testConnection') }}
</button>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter une priorité"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter une priorité
</button>
</div>
<DataTable

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un statut"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un statut
</button>
</div>
<DataTable

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un tag"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un tag
</button>
</div>
<DataTable

View File

@@ -2,13 +2,12 @@
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un utilisateur"
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un utilisateur
</button>
</div>
<DataTable

View File

@@ -1,121 +0,0 @@
<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>
<MalioInputPassword
v-model="form.password"
:label="$t('zimbra.settings.password')"
input-class="w-full"
/>
<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">
<MalioButton
:label="$t('zimbra.settings.save')"
button-class="w-auto px-4"
:disabled="isSaving"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('zimbra.settings.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
/>
</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>

View File

@@ -29,22 +29,22 @@
</div>
<div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<MalioButton
<button
v-if="canEdit && !isEditing"
variant="tertiary"
icon-name="mdi:pencil-outline"
icon-position="left"
button-class="w-auto px-3"
:label="$t('common.edit')"
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"
/>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
>
<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>
@@ -73,7 +73,6 @@
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>
@@ -90,18 +89,21 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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"
/>
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
>
{{ $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>
@@ -189,7 +191,7 @@
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
@@ -241,7 +243,7 @@ const canEdit = computed(() => {
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
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
return false
})
@@ -268,7 +270,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
await clientTicketService.update(props.ticket.id, data as any)
isEditing.value = false
emit('refresh')
} finally {

View File

@@ -1,13 +1,11 @@
<template>
<div>
<!-- Trigger button -->
<MalioButton
variant="tertiary"
icon-name="mdi:ticket-outline"
icon-position="left"
button-class="w-auto px-3 sm:px-4 shrink-0"
<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"
@@ -15,7 +13,7 @@
>
{{ totalCount }}
</span>
</MalioButton>
</button>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
@@ -35,13 +33,13 @@
<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>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
<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 -->
@@ -99,13 +97,13 @@
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="16"
<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"
@@ -181,18 +179,19 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
>
{{ $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>

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
@@ -35,15 +35,16 @@
/>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,21 +1,18 @@
<template>
<div ref="bellRef" class="relative">
<div class="relative">
<MalioButtonIcon
icon="mdi:bell-outline"
aria-label="Notifications"
variant="ghost"
icon-size="24"
button-class="text-white hover:bg-primary-600"
@click="toggleDropdown"
/>
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white pointer-events-none"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</div>
</button>
<Transition name="dropdown">
<div

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"
@@ -54,44 +54,27 @@
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<MalioButton
variant="tertiary"
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
icon-position="left"
button-class="w-auto px-4"
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
<button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting"
@click="handleArchiveToggle"
>
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
</MalioButton>
<MalioButton
v-if="project.taskCount === 0"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
{{ $t('common.delete') }}
</MalioButton>
</button>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">
@@ -121,7 +104,6 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])
@@ -182,7 +164,7 @@ watch(() => props.modelValue, (open) => {
}
})
const { create, update, remove } = useProjectService()
const { create, update } = useProjectService()
async function handleSubmit() {
touched.name = true
@@ -231,19 +213,6 @@ 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

View File

@@ -3,20 +3,20 @@
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
<div class="flex items-center gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-3"
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
<button
type="button"
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
@click="showArchived = !showArchived"
/>
<MalioButton
>
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
</button>
<button
v-if="!showArchived"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un groupe"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
/>
>
+ Ajouter un groupe
</button>
</div>
</div>
@@ -39,20 +39,22 @@
{{ item.description ?? '—' }}
</template>
<template #actions="{ item }">
<MalioButton
<button
v-if="!showArchived && canArchiveGroup(item)"
variant="secondary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-3"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
@click.stop="handleArchive(item)"
/>
<MalioButton
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="showArchived"
variant="secondary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-3"
type="button"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
@click.stop="handleUnarchive(item)"
/>
>
{{ $t('archive.unarchiveButton') }}
</button>
</template>
</DataTable>

View File

@@ -57,14 +57,13 @@
>
{{ link.title }}
</a>
<MalioButtonIcon
icon="mdi:close"
aria-label="Supprimer le lien"
variant="ghost"
icon-size="16"
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
<button
type="button"
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
@click="handleRemove(link.id)"
/>
>
<Icon name="mdi:close" size="16" />
</button>
</div>
</div>

View File

@@ -1,132 +0,0 @@
<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 -->
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="22"
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
@click="emit('bulk-delete')"
/>
</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>

View File

@@ -9,17 +9,7 @@
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-1">
<span
v-if="task.project && task.number"
class="text-xs font-semibold"
:class="showProjectColor ? '' : 'text-neutral-400'"
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
>{{ task.project.code }}{{ task.number }}</span>
<Icon
v-if="task.priority?.label === 'Haute'"
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
@@ -29,14 +19,13 @@
</div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
/>
>
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
</div>
<div class="mt-2 flex items-center gap-1.5">
@@ -55,40 +44,11 @@
>
{{ tag.label }}
</span>
<!-- Deadline badge -->
<span
v-if="task.deadline"
class="rounded-full px-2 py-0.5 text-xs 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="14"
/>
<!-- Recurrence icon -->
<Icon
v-if="task.recurrence"
name="mdi:repeat"
class="text-blue-500"
size="14"
/>
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="ml-auto h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
:class="task.collaborators?.length ? '' : 'ml-auto'"
class="ml-auto"
/>
<span
v-else
@@ -103,12 +63,9 @@
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
const props = defineProps<{
task: Task
showProjectColor?: boolean
}>(), {
showProjectColor: false,
})
}>()
const emit = defineEmits<{
(e: 'click'): void
@@ -130,18 +87,6 @@ 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))

View File

@@ -32,15 +32,14 @@
</div>
<!-- Delete button -->
<MalioButtonIcon
<button
v-if="isAdmin"
icon="heroicons:x-mark"
aria-label="Supprimer"
variant="ghost"
icon-size="16"
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
type="button"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)"
/>
>
<Icon name="heroicons:x-mark" class="h-4 w-4" />
</button>
</div>
</div>
</div>

View File

@@ -12,34 +12,28 @@
ref="overlayRef"
>
<!-- Close button -->
<MalioButtonIcon
icon="heroicons:x-mark"
aria-label="Fermer"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('close')"
/>
>
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
<!-- Navigation arrows -->
<MalioButtonIcon
<button
v-if="hasPrev"
icon="heroicons:chevron-left"
aria-label="Précédent"
variant="ghost"
icon-size="24"
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('prev')"
/>
<MalioButtonIcon
>
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
</button>
<button
v-if="hasNext"
icon="heroicons:chevron-right"
aria-label="Suivant"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('next')"
/>
>
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
</button>
<!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">

View File

@@ -0,0 +1,327 @@
<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>

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -10,15 +10,16 @@
/>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -38,22 +38,24 @@
<!-- Actions -->
<div class="flex gap-1">
<MalioButtonIcon
<button
v-if="activeTab === 'branches'"
icon="mdi:content-copy"
:aria-label="$t('gitea.branch.copy')"
variant="ghost"
icon-size="14"
type="button"
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
:title="$t('gitea.branch.copy')"
@click="handleCopy"
/>
<MalioButton
>
<Icon name="mdi:content-copy" size="14" />
</button>
<button
v-if="activeTab === 'branches'"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-2.5 py-1.5 text-xs"
:label="$t('gitea.branch.create')"
type="button"
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
@click="showCreateForm = !showCreateForm"
/>
>
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
{{ $t('gitea.branch.create') }}
</button>
</div>
</div>
@@ -77,12 +79,14 @@
:label="$t('gitea.branch.baseBranch')"
input-class="w-full"
/>
<MalioButton
:label="isCreating ? '...' : $t('gitea.branch.create')"
button-class="w-auto px-4 mb-[2px] text-xs"
<button
type="button"
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
:disabled="isCreating"
@click="handleCreate"
/>
>
{{ isCreating ? '...' : $t('gitea.branch.create') }}
</button>
</div>
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
{{ branchPreview }}

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
@@ -25,31 +25,34 @@
</div>
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<MalioButton
<button
v-if="canArchive"
variant="secondary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
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"
/>
<MalioButton
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
variant="secondary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
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"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
>
{{ $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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,152 +0,0 @@
<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">
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
/>
<div class="flex items-center gap-1">
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<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>
</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>

View File

@@ -24,16 +24,16 @@
{{ task.project.code }}-{{ task.number }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
</h2>
</div>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close"
/>
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<!-- Client ticket link -->
@@ -56,25 +56,6 @@
<!-- 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"
@@ -170,39 +151,12 @@
</div>
</div>
<!-- Collaborators -->
<div v-if="collaboratorOptions.length" class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
<div class="flex flex-wrap gap-2">
<label
v-for="user in collaboratorOptions"
:key="user.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.collaboratorIds.includes(user.value)
? 'bg-primary-500 text-white shadow-sm'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="user.value"
:checked="form.collaboratorIds.includes(user.value)"
@change="toggleCollaborator(user.value)"
/>
{{ user.label }}
</label>
</div>
</div>
<!-- Description -->
<div class="mt-5">
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
:size="3"
/>
</div>
@@ -242,242 +196,54 @@
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
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
<MalioButton
<button
v-if="isEditing"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
type="button"
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
/>
>
Supprimer
</button>
<div class="flex gap-3">
<MalioButton
<button
v-if="canArchive"
variant="tertiary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleArchive"
/>
<MalioButton
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleUnarchive"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="close"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</div>
</form>
@@ -513,7 +279,6 @@ 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'
@@ -548,7 +313,6 @@ function close() {
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()
@@ -568,26 +332,10 @@ const form = reactive({
effortId: null as number | null,
priorityId: null as number | null,
assigneeId: null as number | null,
collaboratorIds: [] as number[],
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({
@@ -611,20 +359,8 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const collaboratorOptions = computed(() =>
props.users
.filter(u => u.id !== form.assigneeId)
.map(u => ({ label: u.username, value: u.id }))
)
watch(() => form.assigneeId, (newAssigneeId) => {
if (newAssigneeId) {
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
}
})
const groupOptions = computed(() => {
let filtered = props.groups.filter(g => !g.archived)
let filtered = props.groups
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
@@ -661,28 +397,6 @@ function toggleTag(id: number) {
}
}
function toggleCollaborator(userId: number) {
const idx = form.collaboratorIds.indexOf(userId)
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
else form.collaboratorIds.push(userId)
}
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 ?? ''
@@ -691,46 +405,9 @@ function populateForm(task: Task | null) {
form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
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 = ''
@@ -738,26 +415,10 @@ function populateForm(task: Task | null) {
form.effortId = null
form.priorityId = null
form.assigneeId = null
form.collaboratorIds = []
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
@@ -765,7 +426,6 @@ function populateForm(task: Task | null) {
watch(() => props.modelValue, async (open) => {
if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task)
@@ -799,7 +459,6 @@ watch(() => props.task, (task) => {
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[]>([])
@@ -909,7 +568,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
@@ -951,47 +610,16 @@ async function handleSubmit() {
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,
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
scheduledStart: form.scheduledStart || null,
scheduledEnd: form.scheduledEnd || null,
deadline: form.deadline || null,
syncToCalendar: form.syncToCalendar,
}
let savedTask: Task
if (isEditing.value && props.task) {
savedTask = await update(props.task.id, payload)
await update(props.task.id, payload)
} else {
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)
await create(payload)
}
emit('saved')

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -13,15 +13,16 @@
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -31,15 +31,16 @@
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -13,15 +13,16 @@
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="blockEl"
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
:style="blockStyle"
:class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)"
@@ -17,39 +17,38 @@
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Top: title + project -->
<div class="min-w-0">
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Bottom: tags left, duration right -->
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
<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">
<span
v-for="tag in visibleTags"
v-for="tag in entry.tags"
:key="tag.id"
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
:style="{ backgroundColor: tag.color }"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
>
<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>
<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>
</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 -->
</div>
<!-- Resize handle bottom (outside block) -->
@@ -117,22 +116,10 @@ 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
@@ -140,28 +127,13 @@ const blockStyle = computed(() => {
const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100
const base: Record<string, string> = {
return {
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 ---

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -97,34 +97,27 @@
</div>
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<MalioButton
<button
v-if="isEditing"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
type="button"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
@click="onDelete"
/>
<div class="flex gap-2">
<MalioButton
v-if="isEditing"
variant="secondary"
label="Dupliquer"
button-class="w-auto px-4"
@click="onDuplicate"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-4"
@click="onSubmit"
/>
</div>
>
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>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { TimeEntry } 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'
@@ -238,26 +231,6 @@ 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()
@@ -284,7 +257,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as TimeEntryWrite)
await create(payload as any)
}
emit('saved')

View File

@@ -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">
{{ $t('timeEntries.noEntries') }}
Aucune activité pour cette période
</div>
<div
v-for="entry in sortedEntries"
:key="entry.id"
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
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"
@click="emit('editEntry', entry)"
>
<!-- Color bar -->
@@ -18,14 +18,14 @@
<!-- Main info -->
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || $t('common.untitled') }}
</div>
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || 'Sans titre' }}
</span>
<span
v-for="tag in entry.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
@@ -54,14 +54,13 @@
</div>
<!-- Delete action -->
<MalioButtonIcon
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="18"
button-class="shrink-0 text-neutral-300 opacity-0 hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
<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"
@click.stop="emit('deleteEntry', entry)"
/>
>
<Icon name="mdi:delete-outline" size="18" />
</button>
</div>
</div>
</template>

View File

@@ -1,27 +1,27 @@
<template>
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- 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>
<!-- 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 }}
</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>
<!-- Columns -->
<div class="relative flex">
<!-- Grid body -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
<!-- 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 || $t('common.untitled') }}</div>
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
<div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
@@ -134,16 +134,13 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div>
</div>
</div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
@@ -201,11 +198,14 @@ function getScrollParent(): HTMLElement | null {
// Scroll to current hour on mount
onMounted(() => {
nextTick(() => {
if (!gridBodyEl.value) return
if (!calendarEl.value) return
const scrollParent = getScrollParent()
if (!scrollParent) return
const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
const calendarTop = calendarEl.value.offsetTop
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
scrollParent.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 || t('common.untitled'),
title: entry.title || 'Sans titre',
color: entry.project?.color ?? '#94a3b8',
durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),

View File

@@ -1,205 +0,0 @@
<template>
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
<div class="flex flex-col gap-6 p-4">
<!-- Period presets -->
<div>
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
<div class="flex flex-col gap-2">
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="currentMonth"
:label="$t('timeEntries.exportCurrentMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="lastMonth"
:label="$t('timeEntries.exportLastMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="custom"
:label="$t('timeEntries.exportCustomPeriod')"
/>
</div>
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
<input
v-model="customFrom"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
<input
v-model="customTo"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
</div>
</div>
<!-- User filter (admin only) -->
<div v-if="isAdmin" class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedUserIds"
:options="userOptions"
:label="$t('timeEntries.exportUsers')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Client filter -->
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedClientId"
:options="clientOptions"
:label="$t('timeEntries.exportClient')"
:empty-option-label="$t('timeEntries.exportAllClients')"
min-width="!w-full"
/>
</div>
<!-- Project filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedProjectIds"
:options="filteredProjectOptions"
:label="$t('timeEntries.exportProjects')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Tag filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedTagIds"
:options="tagOptions"
:label="$t('timeEntries.exportTags')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
/>
</div>
<!-- Export button -->
<MalioButton
:label="$t('timeEntries.export')"
icon-name="mdi:download"
icon-position="left"
button-class="w-full"
@click="doExport"
/>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
users: UserData[]
projects: Project[]
tags: TaskTag[]
clients: Client[]
}>()
const isOpen = defineModel<boolean>({ default: false })
const emit = defineEmits<{
(e: 'export', params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): void
}>()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
const customFrom = ref('')
const customTo = ref('')
const selectedUserIds = ref<number[]>([])
const selectedClientId = ref<number | null>(null)
const selectedProjectIds = ref<number[]>([])
const selectedTagIds = ref<number[]>([])
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id }))
)
const filteredProjectOptions = computed(() => {
let list = props.projects
if (selectedClientId.value) {
list = list.filter(p => p.client?.id === selectedClientId.value)
}
return list.map(p => ({ label: p.name, value: p.id }))
})
const tagOptions = computed(() =>
props.tags.map(t => ({ label: t.label, value: t.id }))
)
// Reset project selection when client changes
watch(selectedClientId, () => {
selectedProjectIds.value = []
})
function getDateRange(): { after: string; before: string } {
const now = new Date()
if (periodMode.value === 'currentMonth') {
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
if (periodMode.value === 'lastMonth') {
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const last = new Date(now.getFullYear(), now.getMonth(), 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
return {
after: customFrom.value,
before: customTo.value,
}
}
function doExport() {
const { after, before } = getDateRange()
if (!after || !before) return
emit('export', {
after,
before,
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
client: selectedClientId.value ?? undefined,
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
})
isOpen.value = false
}
</script>

View File

@@ -13,13 +13,13 @@
>
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="24"
<button
type="button"
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
@click="close"
/>
>
<Icon name="mdi:close" size="24" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<slot />

View File

@@ -1,26 +1,13 @@
<template>
<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]">
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
<div class="flex h-full items-center justify-between">
<MalioButtonIcon
icon="mdi:menu"
aria-label="Menu"
variant="ghost"
icon-size="24"
button-class="lg:hidden text-white hover:bg-primary-600"
<button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@click="ui.openMobileSidebar()"
/>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
</div>
>
<Icon name="mdi:menu" size="24" />
</button>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<MalioButtonIcon
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
variant="ghost"
icon-size="22"
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
@click="ui.toggleDarkMode()"
/>
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />

View File

@@ -9,18 +9,20 @@
{{ $t('taskDocuments.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
/>
>
Supprimer
</button>
</div>
</div>
</div>

View File

@@ -1,56 +0,0 @@
<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">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</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>

View File

@@ -4,36 +4,39 @@
<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('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
Choisissez les déplacer :
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
label="Déplacer vers"
empty-option-label="Backlog (sans statut)"
min-width="w-full"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
>
Annuler
</button>
<button
type="button"
class="rounded-md bg-[red-600] px-4 py-2 text-sm font-semibold text-white hover:bg-[red-700] disabled:opacity-50"
:disabled="isProcessing"
@click="confirm"
/>
>
Supprimer
</button>
</div>
</div>
</div>

View File

@@ -9,18 +9,20 @@
{{ $t('tasks.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
<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"
/>
<MalioButton
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
>
Annuler
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
/>
>
Supprimer
</button>
</div>
</div>
</div>

View File

@@ -35,15 +35,13 @@
<td v-if="deletable || $slots.actions" class="px-4 py-3">
<div class="flex items-center gap-2">
<slot name="actions" :item="item" />
<MalioButtonIcon
<button
v-if="deletable"
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
class="text-neutral-400 transition-colors hover:text-red-500"
@click.stop="$emit('delete', item)"
/>
>
<Icon name="mdi:delete-outline" size="20" />
</button>
</div>
</td>
</tr>

View File

@@ -1,5 +1,5 @@
<template>
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
<div class="date-filter">
<VueDatePicker
ref="datepicker"
v-model="internalValue"
@@ -14,11 +14,45 @@
@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>
<div class="flex items-center gap-1">
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('day')"
>
{{ t('common.day') }}
</button>
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('week')"
>
{{ t('common.weekShort') }}
</button>
</div>
<div class="relative cursor-pointer">
<input
:value="displayValue"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="t('common.dateFilter')"
readonly
/>
<button
v-if="internalValue"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</div>
</template>
<template #action-buttons>
@@ -51,7 +85,6 @@ const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
pickerMode?: 'day' | 'week'
}>()
const emit = defineEmits<{
@@ -59,7 +92,7 @@ const emit = defineEmits<{
}>()
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = computed(() => props.pickerMode ?? 'week')
const mode = ref<'day' | 'week'>('week')
const internalValue = ref<Date | Date[] | null>(null)
const displayValue = computed(() => {
@@ -100,6 +133,13 @@ function formatShortDate(d: Date): string {
return `${day}/${month}`
}
function switchMode(newMode: 'day' | 'week') {
if (mode.value === newMode) return
mode.value = newMode
internalValue.value = null
emit('update:modelValue', null)
}
function onUpdate(value: Date | Date[] | null) {
if (!value) {
emit('update:modelValue', null)
@@ -123,6 +163,7 @@ function onClear() {
}
function selectToday() {
mode.value = 'day'
const today = new Date()
today.setHours(0, 0, 0, 0)
internalValue.value = today
@@ -130,6 +171,7 @@ function selectToday() {
}
function selectThisWeek() {
mode.value = 'week'
const now = new Date()
const day = now.getDay()
const monday = new Date(now)

View File

@@ -1,11 +1,11 @@
<template>
<button
class="flex items-center justify-center gap-2 text-sm font-semibold text-white transition"
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
:class="[
timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600',
collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
collapsed ? 'px-2' : 'px-4'
]"
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"

View File

@@ -18,18 +18,21 @@
</div>
<div class="flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
<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')"
/>
<MalioButton
:label="$t('common.confirm')"
button-class="w-auto px-4"
>
{{ $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>

View File

@@ -1,5 +1,5 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.username"
@@ -8,11 +8,12 @@
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true"
/>
<MalioInputPassword
<MalioInputText
v-model="form.password"
label="Mot de passe"
input-class="w-full"
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
type="password"
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
@blur="touched.password = true"
/>
@@ -69,15 +70,16 @@
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
<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"
@click="handleSubmit"
/>
>
Enregistrer
</button>
</div>
</form>
</MalioDrawer>
</AppDrawer>
</template>
<script setup lang="ts">
@@ -88,8 +90,6 @@ 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,7 +114,7 @@ const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: t('common.noClient'), value: null as number | null },
{ label: 'Aucun client', value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
@@ -146,13 +146,6 @@ 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) {
@@ -194,12 +187,10 @@ async function handleSubmit() {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
}
if (form.password) {
payload.plainPassword = form.password
payload.password = form.password
}
if (isEditing.value && props.item) {

View File

@@ -177,16 +177,13 @@ export function useApi(): ApiClient {
) {
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 (!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')
}
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 })

View File

@@ -5,13 +5,11 @@ export function useAvatarService() {
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',
}
)
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(userId: number): Promise<void> {

View File

@@ -22,69 +22,43 @@
"clients": {
"created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client",
"editClient": "Modifier un client"
"deleted": "Client supprimé avec succès."
},
"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",
"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."
"hideArchived": "Masquer les projets archivés"
},
"taskStatuses": {
"created": "Statut créé avec succès.",
"updated": "Statut mis à jour 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)"
"deleted": "Statut supprimé avec succès."
},
"taskEfforts": {
"created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.",
"deleted": "Effort supprimé avec succès.",
"addEffort": "Ajouter un effort",
"editEffort": "Modifier un effort"
"deleted": "Effort supprimé avec succès."
},
"taskPriorities": {
"created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour avec succès.",
"deleted": "Priorité supprimée avec succès.",
"addPriority": "Ajouter une priorité",
"editPriority": "Modifier une priorité"
"deleted": "Priorité supprimée avec succès."
},
"taskTags": {
"created": "Tag créé avec succès.",
"updated": "Tag mis à jour avec succès.",
"deleted": "Tag supprimé avec succès.",
"addTag": "Ajouter un tag",
"editTag": "Modifier un tag"
"deleted": "Tag supprimé avec succès."
},
"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.",
"addGroup": "Ajouter un groupe",
"editGroup": "Modifier un groupe"
"unarchived": "Groupe désarchivé avec succès."
},
"taskDocuments": {
"title": "Documents",
@@ -104,79 +78,17 @@
"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.",
"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"
}
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
},
"users": {
"created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
"deleted": "Utilisateur supprimé avec succès."
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps",
"export": "Exporter",
"exportTitle": "Exporter les temps",
"exportCurrentMonth": "Mois en cours",
"exportLastMonth": "Mois dernier",
"exportCustomPeriod": "Période personnalisée",
"exportFrom": "Du",
"exportTo": "Au",
"exportUsers": "Utilisateurs",
"exportClient": "Client",
"exportProjects": "Projets",
"exportTags": "Tags",
"exportAllClients": "Tous les clients",
"exportLoading": "Export en cours...",
"exportSuccess": "Export terminé !",
"exportError": "Erreur lors de l'export."
"deleted": "Temps supprimé"
},
"archive": {
"title": "Archives",
@@ -201,11 +113,7 @@
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog",
"createTask": "Créer une tâche",
"sortBy": "Trier par",
"sortDefault": "Par défaut",
"sortDeadline": "Échéance",
"sortScheduledStart": "Date planifiée"
"createTask": "Créer une tâche"
},
"dashboard": {
"title": "Tableau de bord",
@@ -261,12 +169,7 @@
"cancel": "Annuler",
"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",
@@ -417,35 +320,5 @@
"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"
}
}

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
<img
v-if="!sidebarIsCollapsed"
src="/malio.png"
@@ -26,9 +26,9 @@
/>
<img
v-else
src="/LOGO_CARRE.png"
src="/malio.png"
alt="Logo"
class="w-[46px] h-[55px]"
class="h-8 w-8 object-cover object-left"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@@ -86,18 +86,11 @@
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:calendar-edit-outline"
icon="mdi:clock-outline"
label="Suivi de temps"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
@@ -115,21 +108,19 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex items-center justify-center p-4">
<div class="flex flex-col gap-2 items-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 min-w-0">
@@ -157,7 +148,6 @@ 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()
@@ -221,9 +211,9 @@ async function loadRefData() {
if (refData.loaded) return
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
])
refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData)

View File

@@ -1,7 +0,0 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
return navigateTo('/')
}
})

View File

@@ -2,7 +2,6 @@ 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 || '/')
@@ -24,6 +23,14 @@ export default defineNuxtConfig({
devServer: {
port: 3002,
},
nitro: {
devProxy: {
'/api': {
target: 'http://nginx',
changeOrigin: true,
},
},
},
components: [
{path: '~/components', pathPrefix: false},
],

View File

@@ -7,7 +7,7 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.1.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -76,6 +76,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1037,7 +1038,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1047,7 +1047,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
@@ -1062,7 +1061,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1"
},
@@ -1075,7 +1073,6 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1088,7 +1085,6 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
@@ -1098,7 +1094,6 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
@@ -1174,7 +1169,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1184,7 +1178,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1198,7 +1191,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1212,7 +1204,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -2212,9 +2203,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.2.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.0/layer-ui-1.2.0.tgz",
"integrity": "sha512-/D/p7Tz5t8xsZ+qL4kwBs2XXA/yNJpwF5C8pbSrz06Z8Je/Yut2J4KT1YpPHcfyFFE3TB8TpV0Okg/29aN6Ggg==",
"version": "1.1.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.1.0/layer-ui-1.1.0.tgz",
"integrity": "sha512-mc+kOK+EDfo6ZZcE0/FaVnvDyIDJrigkgOzvL8rxnpljXEiRlKj5673e5e6ZIoOyKFqktzbJXzFr4V6UBD0wPg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -2492,6 +2483,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
"license": "MIT",
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2564,6 +2556,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.27",
"defu": "^6.1.4",
@@ -3210,6 +3203,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.95.0"
},
@@ -5315,8 +5309,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -5328,8 +5321,7 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -5670,6 +5662,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
@@ -5919,6 +5912,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5958,7 +5952,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6290,6 +6283,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6483,6 +6477,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6611,6 +6606,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6752,6 +6748,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -6788,6 +6785,7 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -7343,8 +7341,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -7850,7 +7847,6 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
@@ -7881,7 +7877,6 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7894,7 +7889,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7907,7 +7901,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -7920,7 +7913,6 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7930,7 +7922,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
@@ -7948,7 +7939,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7974,7 +7964,6 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -7987,7 +7976,6 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -8088,8 +8076,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -8117,15 +8104,13 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-npm-meta": {
"version": "1.4.0",
@@ -8170,7 +8155,6 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -8201,7 +8185,6 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -8218,7 +8201,6 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -8231,8 +8213,7 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -8765,7 +8746,6 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -9142,22 +9122,19 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -9235,7 +9212,6 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9496,7 +9472,6 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9581,7 +9556,6 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9973,8 +9947,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -10210,6 +10183,7 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.3.2",
"@nuxt/cli": "^3.33.0",
@@ -10480,7 +10454,6 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10532,6 +10505,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -10615,7 +10589,6 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10631,7 +10604,6 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10674,7 +10646,6 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10778,6 +10749,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10894,6 +10866,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11437,6 +11410,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11487,7 +11461,6 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11524,7 +11497,6 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11886,6 +11858,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12668,6 +12641,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -13008,7 +12982,6 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -13076,6 +13049,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13511,7 +13485,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13536,6 +13509,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13897,6 +13871,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -13961,6 +13936,7 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
@@ -13982,6 +13958,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -14034,7 +14011,6 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14203,7 +14179,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},

View File

@@ -11,7 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@malio/layer-ui": "^1.2.0",
"@malio/layer-ui": "^1.1.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -27,15 +27,14 @@
<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 = [
@@ -45,9 +44,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']

Some files were not shown because too many files have changed in this diff Show More