Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3149f8a27 | ||
|
|
32aff3d4d3 | ||
|
|
9760de1805 | ||
|
|
f72dd57bd0 | ||
|
|
a8f7c77758 | ||
|
|
a09a415393 | ||
|
|
8208df1ade | ||
|
|
15af8975f0 | ||
|
|
040cbfc588 | ||
|
|
e796741dd8 |
224
.claude/commands/push-tickets-lesstime.md
Normal file
224
.claude/commands/push-tickets-lesstime.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
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
|
||||
@@ -16,6 +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.*",
|
||||
|
||||
505
composer.lock
generated
505
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a764e9ff23705c8d01ee621225395a15",
|
||||
"content-hash": "0bdbfd9abe99ffd23a53df611d8a879c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -1156,6 +1156,85 @@
|
||||
},
|
||||
"time": "2026-01-26T15:45:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/collections",
|
||||
"version": "2.6.0",
|
||||
@@ -2549,6 +2628,191 @@
|
||||
],
|
||||
"time": "2025-12-20T17:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-10T09:58:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mcp/sdk",
|
||||
"version": "v0.4.0",
|
||||
@@ -3315,6 +3579,115 @@
|
||||
},
|
||||
"time": "2025-11-21T15:09:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba",
|
||||
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.1",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"ext-intl": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.5",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
},
|
||||
{
|
||||
"name": "Owen Leibman"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0"
|
||||
},
|
||||
"time": "2026-03-01T00:58:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.2",
|
||||
@@ -3889,6 +4262,57 @@
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/simple-cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/simple-cache.git",
|
||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\SimpleCache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interfaces for simple caching",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"caching",
|
||||
"psr",
|
||||
"psr-16",
|
||||
"simple-cache"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-10-29T13:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/uri",
|
||||
"version": "3.0.2",
|
||||
@@ -8945,85 +9369,6 @@
|
||||
],
|
||||
"time": "2022-12-23T10:58:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
"version": "3.4.4",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.8'
|
||||
app.version: '0.3.10'
|
||||
|
||||
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
@@ -0,0 +1,777 @@
|
||||
# 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"
|
||||
```
|
||||
144
docs/superpowers/specs/2026-03-24-time-entry-export-design.md
Normal file
144
docs/superpowers/specs/2026-03-24-time-entry-export-design.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 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
|
||||
@@ -161,7 +161,8 @@
|
||||
"deleted": "Temps supprimé",
|
||||
"noEntries": "Aucune activité pour cette période",
|
||||
"addEntry": "Ajouter une Activité",
|
||||
"editEntry": "Modifier un temps"
|
||||
"editEntry": "Modifier un temps",
|
||||
"export": "Exporter"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,6 +305,40 @@ async function onDelete(entry: TimeEntry) {
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
|
||||
@@ -50,5 +50,23 @@ export function useTimeEntryService() {
|
||||
})
|
||||
}
|
||||
|
||||
return { getByDateRange, getActive, create, update, remove }
|
||||
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()}`
|
||||
}
|
||||
|
||||
return { getByDateRange, getActive, create, update, remove, getExportUrl }
|
||||
}
|
||||
|
||||
98
src/Controller/TimeEntryExportController.php
Normal file
98
src/Controller/TimeEntryExportController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?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 DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -26,4 +28,46 @@ class TimeEntryRepository extends ServiceEntityRepository
|
||||
'stoppedAt' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|int[] $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();
|
||||
}
|
||||
}
|
||||
|
||||
314
src/Service/TimeEntryExportService.php
Normal file
314
src/Service/TimeEntryExportService.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
use function count;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user