Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e0c4052c | ||
|
|
22373a0b87 | ||
|
|
d7968af525 | ||
| df2a48c20d | |||
| 7f1c02256b | |||
| fdc9b8b60d | |||
| 1025fed0d1 | |||
| 0331d94ca5 | |||
| 755c39a0f6 | |||
| 8f8eeddd91 | |||
| 548b101d82 | |||
|
|
e3149f8a27 | ||
|
|
32aff3d4d3 | ||
|
|
9760de1805 | ||
|
|
f72dd57bd0 | ||
|
|
a8f7c77758 | ||
|
|
a09a415393 | ||
|
|
8208df1ade | ||
|
|
15af8975f0 | ||
|
|
040cbfc588 | ||
|
|
e796741dd8 | ||
|
|
9e7d196443 | ||
|
|
3e9a0c93eb | ||
|
|
1d533d1d28 | ||
|
|
efa42b6039 | ||
|
|
7b0c2d9fba | ||
|
|
4ce0214ec9 | ||
|
|
43304bebcc | ||
|
|
6668af73a7 | ||
|
|
ff9a6763c3 | ||
|
|
db5b3d39f9 | ||
|
|
1fdc68c66d | ||
|
|
99b664cdd8 | ||
|
|
fd1da75fd7 | ||
|
|
66264e3b8c | ||
|
|
a89fa6a7af | ||
|
|
6862944726 | ||
|
|
e00c33d20b | ||
|
|
1aa72c3b56 | ||
|
|
6a8e406cc5 | ||
|
|
83b42139b2 | ||
|
|
1bdd3883aa | ||
|
|
22c3c3dbd1 | ||
|
|
cb768e0ce1 | ||
|
|
b3d317284e | ||
|
|
5a47adace5 | ||
|
|
75c53632c8 | ||
|
|
97a8afe559 | ||
|
|
bae6d10ece | ||
|
|
a0306bb5b2 | ||
|
|
7e36b6fd49 | ||
|
|
e688c69438 | ||
|
|
e640e715bb | ||
|
|
6784ee9ead | ||
|
|
fc6b6587f9 | ||
|
|
aa38e20c00 | ||
|
|
98370e0478 | ||
|
|
30fb36e668 | ||
|
|
bd01072831 | ||
|
|
df58b09c2e | ||
|
|
26c41f01c0 |
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
|
||||
24
.mcp.json
24
.mcp.json
@@ -1,8 +1,22 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
||||
}
|
||||
},
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec",
|
||||
"-i",
|
||||
"php-lesstime-fpm",
|
||||
"php",
|
||||
"bin/console",
|
||||
"mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -12,10 +12,11 @@ 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)
|
||||
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/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||
src/Enum/ # PHP enums (RecurrenceType)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||
@@ -30,10 +31,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/)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -68,6 +69,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
|
||||
|
||||
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
|
||||
### Tags & Versioning
|
||||
|
||||
- La version de l'app est dans `config/version.yaml` (paramètre `app.version`)
|
||||
- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version
|
||||
- Faire un commit séparé de bump : `chore : bump version to v<X.Y.Z>`
|
||||
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
|
||||
|
||||
### Backend
|
||||
|
||||
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
||||
@@ -97,7 +105,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
|
||||
### MCP Server
|
||||
|
||||
- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking
|
||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
|
||||
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
|
||||
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
||||
@@ -126,3 +134,5 @@ 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)
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"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.*",
|
||||
|
||||
738
composer.lock
generated
738
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": "1a611b09459bb0625242a9a0ea223107",
|
||||
"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,290 @@
|
||||
},
|
||||
"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",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/uri.git",
|
||||
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.64",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpstan/phpstan-phpunit": "^1.4",
|
||||
"phpstan/phpstan-strict-rules": "^1.6",
|
||||
"phpunit/phpunit": "^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Uri\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Functions for making sense out of URIs.",
|
||||
"homepage": "http://sabre.io/uri/",
|
||||
"keywords": [
|
||||
"rfc3986",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/uri/issues",
|
||||
"source": "https://github.com/fruux/sabre-uri"
|
||||
},
|
||||
"time": "2024-09-04T15:30:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/vobject",
|
||||
"version": "4.5.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/vobject.git",
|
||||
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
|
||||
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"suggest": {
|
||||
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||
},
|
||||
"bin": [
|
||||
"bin/vobject",
|
||||
"bin/generate_vcards"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabre\\VObject\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Dominik Tobschall",
|
||||
"email": "dominik@fruux.com",
|
||||
"homepage": "http://tobschall.de/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Ivan Enderlin",
|
||||
"email": "ivan.enderlin@hoa-project.net",
|
||||
"homepage": "http://mnt.io/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||
"homepage": "http://sabre.io/vobject/",
|
||||
"keywords": [
|
||||
"availability",
|
||||
"freebusy",
|
||||
"iCalendar",
|
||||
"ical",
|
||||
"ics",
|
||||
"jCal",
|
||||
"jCard",
|
||||
"recurrence",
|
||||
"rfc2425",
|
||||
"rfc2426",
|
||||
"rfc2739",
|
||||
"rfc4770",
|
||||
"rfc5545",
|
||||
"rfc5546",
|
||||
"rfc6321",
|
||||
"rfc6350",
|
||||
"rfc6351",
|
||||
"rfc6474",
|
||||
"rfc6638",
|
||||
"rfc6715",
|
||||
"rfc6868",
|
||||
"vCalendar",
|
||||
"vCard",
|
||||
"vcf",
|
||||
"xCal",
|
||||
"xCard"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/vobject/issues",
|
||||
"source": "https://github.com/fruux/sabre-vobject"
|
||||
},
|
||||
"time": "2026-01-12T10:45:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/xml",
|
||||
"version": "4.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/xml.git",
|
||||
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"lib-libxml": ">=2.6.20",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"sabre/uri": ">=2.0,<4.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.64",
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpunit/phpunit": "^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/Deserializer/functions.php",
|
||||
"lib/Serializer/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Xml\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Markus Staab",
|
||||
"email": "markus.staab@redaxo.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "sabre/xml is an XML library that you may not hate.",
|
||||
"homepage": "https://sabre.io/xml/",
|
||||
"keywords": [
|
||||
"XMLReader",
|
||||
"XMLWriter",
|
||||
"dom",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/xml/issues",
|
||||
"source": "https://github.com/fruux/sabre-xml"
|
||||
},
|
||||
"time": "2024-09-06T08:00:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.6",
|
||||
@@ -8712,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",
|
||||
|
||||
@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* limiters?: array<string, array{ // Default: []
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
@@ -685,38 +685,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type TwigConfig = array{
|
||||
* form_themes?: list<scalar|Param|null>,
|
||||
* globals?: array<string, array{ // Default: []
|
||||
* id?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* autoescape_service?: scalar|Param|null, // Default: null
|
||||
* autoescape_service_method?: scalar|Param|null, // Default: null
|
||||
* cache?: scalar|Param|null, // Default: true
|
||||
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
|
||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
|
||||
* auto_reload?: scalar|Param|null,
|
||||
* optimizations?: int|Param,
|
||||
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
|
||||
* file_name_pattern?: list<scalar|Param|null>,
|
||||
* paths?: array<string, mixed>,
|
||||
* date?: array{ // The default format options used by the date filter.
|
||||
* format?: scalar|Param|null, // Default: "F j, Y H:i"
|
||||
* interval_format?: scalar|Param|null, // Default: "%d days"
|
||||
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
|
||||
* },
|
||||
* number_format?: array{ // The default format options for the number_format filter.
|
||||
* decimals?: int|Param, // Default: 0
|
||||
* decimal_point?: scalar|Param|null, // Default: "."
|
||||
* thousands_separator?: scalar|Param|null, // Default: ","
|
||||
* },
|
||||
* mailer?: array{
|
||||
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
|
||||
* },
|
||||
* }
|
||||
* @psalm-type SecurityConfig = array{
|
||||
* access_denied_url?: scalar|Param|null, // Default: null
|
||||
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
|
||||
@@ -1291,8 +1259,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
|
||||
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
|
||||
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
|
||||
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true
|
||||
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true
|
||||
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
|
||||
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
|
||||
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
|
||||
* enable_docs?: bool|Param, // Enable the docs // Default: true
|
||||
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
|
||||
@@ -1641,12 +1609,154 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* bubble?: bool|Param, // Default: true
|
||||
* interactive_only?: bool|Param, // Default: false
|
||||
* app_name?: scalar|Param|null, // Default: null
|
||||
* include_stacktraces?: bool|Param, // Default: false
|
||||
* process_psr_3_messages?: array{
|
||||
* enabled?: bool|Param|null, // Default: null
|
||||
* date_format?: scalar|Param|null,
|
||||
* remove_used_context_fields?: bool|Param,
|
||||
* },
|
||||
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
* file_permission?: scalar|Param|null, // Default: null
|
||||
* use_locking?: bool|Param, // Default: false
|
||||
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||
* ident?: scalar|Param|null, // Default: false
|
||||
* logopts?: scalar|Param|null, // Default: 1
|
||||
* facility?: scalar|Param|null, // Default: "user"
|
||||
* max_files?: scalar|Param|null, // Default: 0
|
||||
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||
* activation_strategy?: scalar|Param|null, // Default: null
|
||||
* stop_buffering?: bool|Param, // Default: true
|
||||
* passthru_level?: scalar|Param|null, // Default: null
|
||||
* excluded_http_codes?: list<array{ // Default: []
|
||||
* code?: scalar|Param|null,
|
||||
* urls?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* accepted_levels?: list<scalar|Param|null>,
|
||||
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||
* buffer_size?: scalar|Param|null, // Default: 0
|
||||
* flush_on_overflow?: bool|Param, // Default: false
|
||||
* handler?: scalar|Param|null,
|
||||
* url?: scalar|Param|null,
|
||||
* exchange?: scalar|Param|null,
|
||||
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||
* channel?: scalar|Param|null, // Default: null
|
||||
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||
* use_attachment?: scalar|Param|null, // Default: true
|
||||
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||
* include_extra?: scalar|Param|null, // Default: false
|
||||
* icon_emoji?: scalar|Param|null, // Default: null
|
||||
* webhook_url?: scalar|Param|null,
|
||||
* exclude_fields?: list<scalar|Param|null>,
|
||||
* token?: scalar|Param|null,
|
||||
* region?: scalar|Param|null,
|
||||
* source?: scalar|Param|null,
|
||||
* use_ssl?: bool|Param, // Default: true
|
||||
* user?: mixed,
|
||||
* title?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 514
|
||||
* config?: list<scalar|Param|null>,
|
||||
* members?: list<scalar|Param|null>,
|
||||
* connection_string?: scalar|Param|null,
|
||||
* timeout?: scalar|Param|null,
|
||||
* time?: scalar|Param|null, // Default: 60
|
||||
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||
* store?: scalar|Param|null, // Default: null
|
||||
* connection_timeout?: scalar|Param|null,
|
||||
* persistent?: bool|Param,
|
||||
* message_type?: scalar|Param|null, // Default: 0
|
||||
* parse_mode?: scalar|Param|null, // Default: null
|
||||
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||
* disable_notification?: bool|Param|null, // Default: null
|
||||
* split_long_messages?: bool|Param, // Default: false
|
||||
* delay_between_messages?: bool|Param, // Default: false
|
||||
* topic?: int|Param, // Default: null
|
||||
* factor?: int|Param, // Default: 1
|
||||
* tags?: list<scalar|Param|null>,
|
||||
* console_formatter_options?: mixed, // Default: []
|
||||
* formatter?: scalar|Param|null,
|
||||
* nested?: bool|Param, // Default: false
|
||||
* publisher?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hostname?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 12201
|
||||
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||
* encoder?: "json"|"compressed_json"|Param,
|
||||
* },
|
||||
* mongodb?: string|array{
|
||||
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||
* uri?: scalar|Param|null,
|
||||
* username?: scalar|Param|null,
|
||||
* password?: scalar|Param|null,
|
||||
* database?: scalar|Param|null, // Default: "monolog"
|
||||
* collection?: scalar|Param|null, // Default: "logs"
|
||||
* },
|
||||
* elasticsearch?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hosts?: list<scalar|Param|null>,
|
||||
* host?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 9200
|
||||
* transport?: scalar|Param|null, // Default: "Http"
|
||||
* user?: scalar|Param|null, // Default: null
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* index?: scalar|Param|null, // Default: "monolog"
|
||||
* document_type?: scalar|Param|null, // Default: "logs"
|
||||
* ignore_error?: scalar|Param|null, // Default: false
|
||||
* redis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 6379
|
||||
* database?: scalar|Param|null, // Default: 0
|
||||
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||
* },
|
||||
* predis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* },
|
||||
* from_email?: scalar|Param|null,
|
||||
* to_email?: list<scalar|Param|null>,
|
||||
* subject?: scalar|Param|null,
|
||||
* content_type?: scalar|Param|null, // Default: null
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||
* },
|
||||
* channels?: string|array{
|
||||
* type?: scalar|Param|null,
|
||||
* elements?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1654,12 +1764,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1667,13 +1777,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1681,13 +1791,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
@@ -1695,6 +1805,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.5'
|
||||
app.version: '0.3.12'
|
||||
|
||||
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
1465
docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||
```
|
||||
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
278
docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Intégration Calendrier Zimbra CalDAV
|
||||
|
||||
**Date** : 2026-03-19
|
||||
**Statut** : Validé
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.
|
||||
|
||||
## Principes
|
||||
|
||||
- **Push uniquement** : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
|
||||
- **Opt-in** : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
|
||||
- **Sync synchrone** : les appels CalDAV se font au moment de l'action, timeout 5s
|
||||
- **Configuration globale** : un seul compte Zimbra admin pour toute l'instance
|
||||
- **Calendrier d'équipe** : toutes les tâches sync vont dans le même calendrier
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Nouveaux champs sur `Task`
|
||||
|
||||
| Champ | Type | Nullable | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `scheduledStart` | `DateTimeImmutable` | oui | `null` | Début du créneau planifié |
|
||||
| `scheduledEnd` | `DateTimeImmutable` | oui | `null` | Fin du créneau planifié |
|
||||
| `deadline` | `DateTimeImmutable` | oui | `null` | Date d'échéance |
|
||||
| `syncToCalendar` | `bool` | non | `false` | Opt-in pour la sync Zimbra |
|
||||
| `calendarEventUid` | `string` | oui | `null` | UID du VEVENT dans Zimbra |
|
||||
| `calendarTodoUid` | `string` | oui | `null` | UID du VTODO dans Zimbra |
|
||||
| `calendarSyncError` | `string` | oui | `null` | Dernière erreur de sync CalDAV (null = OK) |
|
||||
|
||||
#### Règles de validation
|
||||
|
||||
- `scheduledEnd` requiert `scheduledStart` (et vice versa) — les deux ou aucun
|
||||
- `scheduledEnd` doit être après `scheduledStart`
|
||||
- `syncToCalendar = true` sans aucune date → ignoré silencieusement (pas de sync)
|
||||
- `deadline` est indépendant des dates planifiées (peut exister seul)
|
||||
|
||||
### Nouvelle entité `TaskRecurrence`
|
||||
|
||||
| Champ | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `int` | non | PK auto-increment |
|
||||
| `type` | `RecurrenceType` (PHP enum) | non | Enum backed string : `daily`, `weekly`, `monthly`, `yearly` |
|
||||
| `interval` | `int` | non | Tous les X (jours/semaines/mois/ans) |
|
||||
| `daysOfWeek` | `json` | oui | Jours de la semaine pour hebdo, ex: `["monday","wednesday"]` |
|
||||
| `dayOfMonth` | `int` | oui | Jour du mois pour mensuel, ex: `15` |
|
||||
| `weekOfMonth` | `int` | oui | Semaine du mois, ex: `1` pour "le 1er X du mois" |
|
||||
| `endDate` | `Date` | oui | Fin de la récurrence (null = infini) |
|
||||
| `maxOccurrences` | `int` | oui | Nombre max d'occurrences (alternatif à endDate) |
|
||||
| `occurrenceCount` | `int` | non | Compteur d'occurrences créées (default 0) |
|
||||
|
||||
### Relations
|
||||
|
||||
- `Task.recurrence` → `ManyToOne` vers `TaskRecurrence` (nullable)
|
||||
- `TaskRecurrence.tasks` → `OneToMany` vers `Task`
|
||||
|
||||
### Nouvelle entité `ZimbraConfiguration`
|
||||
|
||||
| Champ | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `int` | non | PK auto-increment |
|
||||
| `serverUrl` | `string` | non | URL CalDAV Zimbra |
|
||||
| `username` | `string` | non | Compte Zimbra |
|
||||
| `encryptedPassword` | `string` | non | Mot de passe chiffré via `TokenEncryptor` (même pattern que `GiteaConfiguration`) |
|
||||
| `calendarPath` | `string` | non | Chemin complet du calendrier, ex: `/dav/user@domain.com/Calendar/` |
|
||||
| `enabled` | `bool` | non | Activer/désactiver la sync (default false) |
|
||||
|
||||
## Service CalDAV
|
||||
|
||||
### `CalDavService`
|
||||
|
||||
Dépendances : `sabre/vobject` pour la génération ICS, requêtes HTTP via `Symfony\Contracts\HttpClient`.
|
||||
|
||||
Le service utilise la `ZimbraConfiguration` pour construire l'URL CalDAV complète : `{serverUrl}{calendarPath}{uid}.ics`. Le mot de passe est déchiffré via `TokenEncryptor` avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.
|
||||
|
||||
#### Méthodes
|
||||
|
||||
- `createEvent(Task): string` — crée un VEVENT (créneau planifié), retourne l'UID
|
||||
- `createTodo(Task): string` — crée un VTODO (deadline), retourne l'UID
|
||||
- `updateEvent(Task): void` — met à jour le VEVENT existant
|
||||
- `updateTodo(Task): void` — met à jour le VTODO existant
|
||||
- `deleteEvent(string $uid): void` — supprime le VEVENT par UID
|
||||
- `deleteTodo(string $uid): void` — supprime le VTODO par UID
|
||||
- `testConnection(): bool` — teste la connexion CalDAV
|
||||
|
||||
#### Format ICS
|
||||
|
||||
Toutes les dates sont envoyées en **UTC** (suffixe `Z`). Les composants sont wrappés dans un document iCalendar complet :
|
||||
|
||||
**VEVENT (créneau planifié)** :
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Lesstime//CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{calendarEventUid}
|
||||
SUMMARY:[PROJET-NUM] Titre de la tâche
|
||||
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
|
||||
DTEND:{scheduledEnd en UTC}
|
||||
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||
RRULE:{rrule si récurrence}
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
**VTODO (deadline)** :
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Lesstime//CalDAV//EN
|
||||
BEGIN:VTODO
|
||||
UID:{calendarTodoUid}
|
||||
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
|
||||
DUE:{deadline en UTC}
|
||||
DESCRIPTION:{description}\n\nLesstime: {url}
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.
|
||||
|
||||
## Logique de sync
|
||||
|
||||
### Déclenchement
|
||||
|
||||
Un **API Platform State Processor** (`TaskCalendarProcessor`) qui décore le persist/remove processor. La sync CalDAV est appelée **après** le flush en BDD, jamais pendant la transaction. Cela garantit :
|
||||
- La tâche est sauvegardée même si Zimbra est down
|
||||
- Pas de blocage de transaction DB par les appels HTTP
|
||||
|
||||
Pour les **MCP tools**, le `CalDavService` doit être appelé explicitement après le `flush()` dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).
|
||||
|
||||
### Matrice d'actions
|
||||
|
||||
| Action Lesstime | Effet CalDAV |
|
||||
|---|---|
|
||||
| Tâche créée/modifiée avec `syncToCalendar=true` et dates renseignées | Crée ou met à jour VEVENT + VTODO |
|
||||
| `syncToCalendar` décoché | Supprime VEVENT + VTODO si existants |
|
||||
| Tâche supprimée | Supprime VEVENT + VTODO si existants |
|
||||
| Tâche récurrente passe en `isFinal` | Tâche archivée (`archived=true`), événements **conservés** dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent |
|
||||
| Dates retirées | Supprime les events correspondants |
|
||||
|
||||
### Gestion des erreurs
|
||||
|
||||
- Timeout CalDAV : 5 secondes
|
||||
- En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
|
||||
- L'erreur est persistée dans `calendarSyncError` (visible dans l'UI comme indicateur rouge)
|
||||
- Les UIDs CalDAV restent `null` si la création a échoué
|
||||
- En cas de succès après un échec précédent, `calendarSyncError` est remis à `null`
|
||||
|
||||
## Tâches récurrentes
|
||||
|
||||
### Comportement
|
||||
|
||||
1. L'utilisateur crée une tâche avec récurrence dans Lesstime
|
||||
2. **Zimbra** : un seul VEVENT avec `RRULE` est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
|
||||
3. **Lesstime** : une seule tâche existe à la fois
|
||||
4. Quand la tâche passe en statut `isFinal` :
|
||||
- La tâche est archivée automatiquement (`archived = true`)
|
||||
- Les événements Zimbra sont **conservés** (historique)
|
||||
- Les `calendarEventUid` et `calendarTodoUid` de la tâche archivée sont **vidés** (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée
|
||||
- Une nouvelle tâche est créée avec :
|
||||
- Même titre, description, assigné, tags, projet, groupe, effort, priorité
|
||||
- Nouveau `number` généré via `findMaxNumberByProjectForUpdate` (même pattern transactionnel que `TaskNumberProcessor`)
|
||||
- Statut réinitialisé au premier statut (position la plus basse)
|
||||
- Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
|
||||
- `calendarEventUid` pointant vers le même VEVENT récurrent
|
||||
- Nouveau `calendarTodoUid` (nouvelle deadline)
|
||||
- `occurrenceCount` incrémenté sur `TaskRecurrence` (avec lock optimiste `@ORM\Version` pour éviter les doublons en cas de concurrence)
|
||||
5. Si `maxOccurrences` ou `endDate` atteint, la récurrence s'arrête (pas de nouvelle tâche créée)
|
||||
|
||||
### Calcul de la prochaine date
|
||||
|
||||
La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :
|
||||
|
||||
- **Daily** : `scheduledStart + interval jours`
|
||||
- **Weekly** : prochain jour de `daysOfWeek` à partir de `scheduledStart + interval semaines`
|
||||
- **Monthly** : même `dayOfMonth` ou même `weekOfMonth`+jour, mois `+ interval`
|
||||
- **Yearly** : même date, année `+ interval`
|
||||
|
||||
La durée du créneau (`scheduledEnd - scheduledStart`) est conservée.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Onglet "Planification" dans TaskModal
|
||||
|
||||
La modale tâche existante aura 2 onglets :
|
||||
|
||||
**Onglet "Détails"** (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe
|
||||
|
||||
**Onglet "Planification"** (nouveau) :
|
||||
|
||||
#### Bloc Dates
|
||||
- Date planifiée début (`datetime-local` picker)
|
||||
- Date planifiée fin (`datetime-local` picker)
|
||||
- Deadline (`date` picker)
|
||||
|
||||
#### Bloc Calendrier
|
||||
- Checkbox "Envoyer au calendrier" (décoché par défaut)
|
||||
- Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)
|
||||
|
||||
#### Bloc Récurrence
|
||||
- Toggle "Tâche récurrente"
|
||||
- Si activé :
|
||||
- Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
|
||||
- Intervalle : "Tous les X ..." (input number)
|
||||
- Conditionnel selon le type :
|
||||
- Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
|
||||
- Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
|
||||
- Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)
|
||||
|
||||
### Affichage des dates
|
||||
|
||||
**Cartes Kanban (`TaskCard`)** :
|
||||
- Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
|
||||
- Icône calendrier si `syncToCalendar` activé
|
||||
- Icône récurrence si tâche récurrente
|
||||
|
||||
**Vue liste (`TaskListItem`)** :
|
||||
- Colonne "Planifié" (date début)
|
||||
- Colonne "Deadline"
|
||||
- Icône récurrence si tâche récurrente
|
||||
|
||||
**Page "Mes tâches"** :
|
||||
- Même affichage que la vue liste
|
||||
- Tri possible par deadline ou date planifiée
|
||||
|
||||
### Page Admin — Configuration Zimbra
|
||||
|
||||
Nouveau bloc dans la page admin existante :
|
||||
|
||||
- URL du serveur CalDAV (input text)
|
||||
- Nom d'utilisateur (input text)
|
||||
- Mot de passe (input password)
|
||||
- Chemin du calendrier (input text)
|
||||
- Toggle activer/désactiver
|
||||
- Bouton "Tester la connexion" (toast succès/erreur)
|
||||
|
||||
Accessible uniquement `ROLE_ADMIN`.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Mise à jour des tools existants
|
||||
|
||||
`create-task` et `update-task` : nouveaux paramètres optionnels :
|
||||
- `scheduledStart` (string datetime ISO)
|
||||
- `scheduledEnd` (string datetime ISO)
|
||||
- `deadline` (string datetime ISO)
|
||||
- `syncToCalendar` (bool)
|
||||
|
||||
### Nouveaux tools
|
||||
|
||||
- `create-task-recurrence` — paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?
|
||||
- `update-task-recurrence` — paramètres : recurrenceId, + mêmes champs optionnels
|
||||
- `delete-task-recurrence` — paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant
|
||||
|
||||
## API Filters
|
||||
|
||||
Ajouter sur `Task` les filtres API Platform suivants :
|
||||
- `DateFilter` sur `scheduledStart`, `scheduledEnd`, `deadline` (pour le tri et filtrage par plage de dates)
|
||||
- `BooleanFilter` sur `syncToCalendar`
|
||||
- `OrderFilter` sur `scheduledStart`, `deadline`
|
||||
|
||||
### Valeurs stockées en JSON (i18n)
|
||||
|
||||
Les `daysOfWeek` dans `TaskRecurrence` sont stockés en anglais (`monday`, `tuesday`...) — les labels traduits sont gérés uniquement côté frontend via i18n.
|
||||
|
||||
## Dépendances PHP
|
||||
|
||||
- `sabre/vobject` — génération/parsing ICS (VEVENT, VTODO, RRULE)
|
||||
- `symfony/http-client` — requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
|
||||
- Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.
|
||||
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
|
||||
248
frontend/assets/css/dark.css
Normal file
248
frontend/assets/css/dark.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Dark theme overrides
|
||||
* Automatically applied when <html class="dark"> is set.
|
||||
* Overrides existing Tailwind utilities so components need zero changes.
|
||||
*/
|
||||
|
||||
/* ── Backgrounds ── */
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1e1f2b !important;
|
||||
}
|
||||
|
||||
.dark .bg-tertiary-500 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-50 {
|
||||
background-color: #262838 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-100 {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .bg-neutral-200 {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
/* ── Hover backgrounds ── */
|
||||
|
||||
.dark .hover\:bg-neutral-50:hover {
|
||||
background-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-100:hover {
|
||||
background-color: #363952 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-200:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-neutral-300:hover {
|
||||
background-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* ── Text ── */
|
||||
|
||||
.dark .text-neutral-900 {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-800 {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-700 {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-600 {
|
||||
color: #8b8b9a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-500 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-400 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark .text-neutral-300 {
|
||||
color: #52525b !important;
|
||||
}
|
||||
|
||||
/* ── Hover text ── */
|
||||
|
||||
.dark .hover\:text-neutral-700:hover {
|
||||
color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-neutral-600:hover {
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* ── Borders ── */
|
||||
|
||||
.dark .border-neutral-200 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-100 {
|
||||
border-color: #2e3045 !important;
|
||||
}
|
||||
|
||||
.dark .border-neutral-300 {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-300:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
.dark .hover\:border-neutral-400:hover {
|
||||
border-color: #4a4d64 !important;
|
||||
}
|
||||
|
||||
/* ── Ring ── */
|
||||
|
||||
.dark .ring-black\/5 {
|
||||
--tw-ring-color: rgb(255 255 255 / 0.05) !important;
|
||||
}
|
||||
|
||||
/* ── Specific component overrides ── */
|
||||
|
||||
/* Modal header bg */
|
||||
.dark .bg-neutral-50\/80 {
|
||||
background-color: rgb(38 40 56 / 0.8) !important;
|
||||
}
|
||||
|
||||
/* Sidebar collapse button */
|
||||
.dark .shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* User dropdown */
|
||||
.dark .shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important;
|
||||
}
|
||||
|
||||
/* Forms: inputs, selects, textareas */
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]),
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
background-color: #1e1f2b !important;
|
||||
color: #e5e5e5 !important;
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"])::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
.dark input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.dark textarea:focus,
|
||||
.dark select:focus {
|
||||
border-color: #222783 !important;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.dark label {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── Malio Layer UI components ── */
|
||||
|
||||
/* MalioSelect: floating label has hardcoded background: white */
|
||||
.dark .floating-label {
|
||||
background: #1e1f2b !important;
|
||||
color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: text-black used for selected value and options */
|
||||
.dark .text-black {
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/60 {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
.dark .text-black\/40 {
|
||||
color: #606070 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-black used when option is selected */
|
||||
.dark .border-black {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: border-m-muted default border */
|
||||
.dark .border-m-muted {
|
||||
border-color: #3a3d54 !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown option hover background */
|
||||
.dark .bg-m-muted\/10 {
|
||||
background-color: rgb(160 174 192 / 0.15) !important;
|
||||
}
|
||||
|
||||
/* MalioSelect: dropdown shadow */
|
||||
.dark .shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
|
||||
}
|
||||
|
||||
/* Checkbox/radio hardcoded black borders */
|
||||
.dark .inp-cbx + .cbx svg {
|
||||
stroke: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.dark .inp-cbx + .cbx {
|
||||
border-color: #a1a1aa !important;
|
||||
}
|
||||
|
||||
/* Red/colored backgrounds for buttons */
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:bg-red-100:hover {
|
||||
background-color: rgb(127 29 29 / 0.3) !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* Datetime/date input color-scheme for dark mode */
|
||||
.dark input[type="datetime-local"],
|
||||
.dark input[type="date"],
|
||||
.dark input[type="time"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1e1f2b;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3a3d54;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4d64;
|
||||
}
|
||||
@@ -32,21 +32,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="$t('bookstack.settings.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('bookstack.settings.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('bookstack.settings.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
: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'">
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un client"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un client
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -92,19 +92,21 @@
|
||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
/>
|
||||
<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"
|
||||
@click.stop="openDeleteConfirm(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -155,19 +157,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,19 +187,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">
|
||||
<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"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="deleteModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un effort"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un effort
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -24,21 +24,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="$t('gitea.settings.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('gitea.settings.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('gitea.settings.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
: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'">
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter une priorité"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter une priorité
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un statut"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un statut
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un tag"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un tag
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un utilisateur"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
122
frontend/components/admin/AdminZimbraTab.vue
Normal file
122
frontend/components/admin/AdminZimbraTab.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('zimbra.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.serverUrl"
|
||||
:label="$t('zimbra.settings.serverUrl')"
|
||||
:placeholder="$t('zimbra.settings.serverUrlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="$t('zimbra.settings.username')"
|
||||
:placeholder="$t('zimbra.settings.usernamePlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.calendarPath"
|
||||
:label="$t('zimbra.settings.calendarPath')"
|
||||
:placeholder="$t('zimbra.settings.calendarPathPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<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>
|
||||
@@ -29,22 +29,22 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="canEdit && !isEditing"
|
||||
type="button"
|
||||
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
:label="$t('common.edit')"
|
||||
@click="startEdit"
|
||||
>
|
||||
<Icon name="mdi:pencil-outline" size="16" />
|
||||
{{ $t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,21 +90,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:ticket-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||
@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"
|
||||
@@ -13,7 +15,7 @@
|
||||
>
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</button>
|
||||
</MalioButton>
|
||||
|
||||
<!-- Panel -->
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
@@ -33,13 +35,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>
|
||||
<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"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -97,13 +99,13 @@
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@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"
|
||||
@@ -179,19 +181,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
@@ -35,16 +35,15 @@
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<template>
|
||||
<div ref="bellRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Icon name="mdi:bell-outline" size="24" />
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
@@ -54,41 +54,44 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
<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"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
<button
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
v-if="project.taskCount === 0"
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -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">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-3"
|
||||
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="!showArchived"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un groupe"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un groupe
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,22 +39,20 @@
|
||||
{{ item.description ?? '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="!showArchived && canArchiveGroup(item)"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleArchive(item)"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchived"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleUnarchive(item)"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
|
||||
@@ -57,13 +57,14 @@
|
||||
>
|
||||
{{ link.title }}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
<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"
|
||||
@click="handleRemove(link.id)"
|
||||
>
|
||||
<Icon name="mdi:close" size="16" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,13 +72,14 @@
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
<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')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
<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() : 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">
|
||||
@@ -54,6 +55,29 @@
|
||||
>
|
||||
{{ 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"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
@@ -100,6 +124,18 @@ function onPlay() {
|
||||
timerStore.startFromTask(props.task)
|
||||
}
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||
|
||||
@@ -32,14 +32,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
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"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,28 +12,34 @@
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
<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"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
||||
</button>
|
||||
/>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="hasPrev"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
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"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
v-if="hasNext"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
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"
|
||||
@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">
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
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 Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -10,16 +10,15 @@
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -38,24 +38,22 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
|
||||
:title="$t('gitea.branch.copy')"
|
||||
icon="mdi:content-copy"
|
||||
:aria-label="$t('gitea.branch.copy')"
|
||||
variant="ghost"
|
||||
icon-size="14"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="14" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||
:label="$t('gitea.branch.create')"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
>
|
||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.create') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,14 +77,12 @@
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||
: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 }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -25,34 +25,31 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
<MalioButton
|
||||
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"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
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"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
: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"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status -->
|
||||
<!-- 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"
|
||||
@@ -50,18 +50,42 @@
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="13"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="13"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
<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)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
@@ -96,6 +120,18 @@ const emit = defineEmits<{
|
||||
|
||||
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
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client ticket link -->
|
||||
@@ -56,6 +56,25 @@
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in ['details', 'planning']"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab as 'details' | 'planning'"
|
||||
>
|
||||
{{ $t(`tasks.${tab}Tab`) }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'details'">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -160,8 +179,6 @@
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -201,54 +218,242 @@
|
||||
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'"
|
||||
>
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -284,6 +489,7 @@ 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'
|
||||
|
||||
@@ -318,6 +524,7 @@ 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()
|
||||
@@ -341,6 +548,21 @@ const form = reactive({
|
||||
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({
|
||||
@@ -365,7 +587,7 @@ const userOptions = computed(() =>
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let filtered = props.groups
|
||||
let filtered = props.groups.filter(g => !g.archived)
|
||||
if (showProjectSelect.value && form.projectId) {
|
||||
filtered = filtered.filter(g => g.project?.id === form.projectId)
|
||||
}
|
||||
@@ -402,6 +624,22 @@ function toggleTag(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const weekDays = computed(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
|
||||
{ value: 'thursday', label: t('tasks.planning.days.thu') },
|
||||
{ value: 'friday', label: t('tasks.planning.days.fri') },
|
||||
{ value: 'saturday', label: t('tasks.planning.days.sat') },
|
||||
{ value: 'sunday', label: t('tasks.planning.days.sun') },
|
||||
])
|
||||
|
||||
function toggleDay(day: string) {
|
||||
const idx = form.recurrenceDaysOfWeek.indexOf(day)
|
||||
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
|
||||
else form.recurrenceDaysOfWeek.push(day)
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
@@ -413,6 +651,42 @@ function populateForm(task: Task | null) {
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||
form.syncToCalendar = task.syncToCalendar ?? false
|
||||
|
||||
if (task.recurrence) {
|
||||
form.isRecurring = true
|
||||
form.recurrenceType = task.recurrence.type
|
||||
form.recurrenceInterval = String(task.recurrence.interval)
|
||||
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
|
||||
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
|
||||
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
|
||||
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
|
||||
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
|
||||
if (task.recurrence.maxOccurrences) {
|
||||
form.recurrenceEnd = 'occurrences'
|
||||
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
|
||||
} else if (task.recurrence.endDate) {
|
||||
form.recurrenceEnd = 'date'
|
||||
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
|
||||
} else {
|
||||
form.recurrenceEnd = 'never'
|
||||
}
|
||||
} else {
|
||||
form.isRecurring = false
|
||||
form.recurrenceType = 'daily'
|
||||
form.recurrenceInterval = '1'
|
||||
form.recurrenceDaysOfWeek = []
|
||||
form.recurrenceDayOfMonth = ''
|
||||
form.monthlyMode = 'dayOfMonth'
|
||||
form.recurrenceWeekOfMonth = 1
|
||||
form.recurrenceWeekDay = 'monday'
|
||||
form.recurrenceEnd = 'never'
|
||||
form.recurrenceMaxOccurrences = ''
|
||||
form.recurrenceEndDate = ''
|
||||
}
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
@@ -424,6 +698,21 @@ function populateForm(task: Task | 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
|
||||
@@ -431,6 +720,7 @@ function populateForm(task: Task | null) {
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
activeTab.value = 'details'
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
populateForm(props.task)
|
||||
@@ -464,6 +754,7 @@ 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[]>([])
|
||||
@@ -619,12 +910,42 @@ async function handleSubmit() {
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
scheduledStart: form.scheduledStart || null,
|
||||
scheduledEnd: form.scheduledEnd || null,
|
||||
deadline: form.deadline || null,
|
||||
syncToCalendar: form.syncToCalendar,
|
||||
}
|
||||
|
||||
let savedTask: Task
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
savedTask = await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
savedTask = await create(payload)
|
||||
}
|
||||
|
||||
// Handle recurrence
|
||||
if (form.isRecurring) {
|
||||
const recPayload = {
|
||||
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
|
||||
interval: parseInt(form.recurrenceInterval) || 1,
|
||||
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
|
||||
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
|
||||
? parseInt(form.recurrenceDayOfMonth) || null : null,
|
||||
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
|
||||
? form.recurrenceWeekOfMonth : null,
|
||||
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
|
||||
maxOccurrences: form.recurrenceEnd === 'occurrences'
|
||||
? parseInt(form.recurrenceMaxOccurrences) || null : null,
|
||||
}
|
||||
|
||||
if (savedTask.recurrence) {
|
||||
await updateRecurrence(savedTask.recurrence.id, recPayload)
|
||||
} else {
|
||||
const recurrence = await createRecurrence(recPayload)
|
||||
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
|
||||
}
|
||||
} else if (isEditing.value && props.task?.recurrence) {
|
||||
await removeRecurrence(props.task.recurrence.id)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -13,16 +13,15 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -31,16 +31,15 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -13,16 +13,15 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -97,33 +97,30 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
variant="secondary"
|
||||
label="Dupliquer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -54,13 +54,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete action -->
|
||||
<button
|
||||
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
:title="$t('common.delete')"
|
||||
<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"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<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 => ({ text: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ text: 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 => ({ text: p.name, value: p.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
props.tags.map(t => ({ text: 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>
|
||||
@@ -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>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<slot />
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
<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]">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<button
|
||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:menu"
|
||||
aria-label="Menu"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||
@click="ui.openMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
|
||||
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
||||
@click="toggleTitle"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<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" />
|
||||
|
||||
@@ -9,20 +9,18 @@
|
||||
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,20 +9,18 @@
|
||||
{{ $t('projects.deleteConfirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,21 +21,19 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-[red-600] px-4 py-2 text-sm font-semibold text-white hover:bg-[red-700] disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isProcessing"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,20 +9,18 @@
|
||||
{{ $t('tasks.deleteConfirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
>
|
||||
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"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,13 +35,15 @@
|
||||
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions" :item="item" />
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="deletable"
|
||||
class="text-neutral-400 transition-colors hover:text-red-500"
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-red-500"
|
||||
@click.stop="$emit('delete', item)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -18,21 +18,18 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.confirm')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="cropping"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('common.confirm') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -70,16 +70,15 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -106,7 +106,47 @@
|
||||
"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"
|
||||
"editTask": "Modifier un ticket",
|
||||
"detailsTab": "Détails",
|
||||
"planningTab": "Planification",
|
||||
"planning": {
|
||||
"dates": "Dates",
|
||||
"scheduledStart": "Début planifié",
|
||||
"scheduledEnd": "Fin planifiée",
|
||||
"deadline": "Deadline",
|
||||
"calendar": "Calendrier",
|
||||
"syncToCalendar": "Envoyer au calendrier Zimbra",
|
||||
"syncOk": "Synchronisé",
|
||||
"recurrence": "Récurrence",
|
||||
"isRecurring": "Tâche récurrente",
|
||||
"type": "Type",
|
||||
"daily": "Quotidien",
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel",
|
||||
"interval": "Intervalle",
|
||||
"daysOfWeek": "Jours de la semaine",
|
||||
"days": {
|
||||
"mon": "Lu",
|
||||
"tue": "Ma",
|
||||
"wed": "Me",
|
||||
"thu": "Je",
|
||||
"fri": "Ve",
|
||||
"sat": "Sa",
|
||||
"sun": "Di"
|
||||
},
|
||||
"dayOfMonth": "Jour du mois",
|
||||
"dayOfMonthLabel": "Jour (1-31)",
|
||||
"weekOfMonth": "Semaine du mois",
|
||||
"weekOfMonthLabel": "Semaine",
|
||||
"dayLabel": "Jour",
|
||||
"endRecurrence": "Fin de la récurrence",
|
||||
"neverEnds": "Jamais",
|
||||
"afterOccurrences": "Après X occurrences",
|
||||
"occurrences": "Occurrences",
|
||||
"onDate": "À une date",
|
||||
"endDate": "Date de fin"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"created": "Utilisateur créé avec succès.",
|
||||
@@ -121,7 +161,22 @@
|
||||
"deleted": "Temps supprimé",
|
||||
"noEntries": "Aucune activité pour cette période",
|
||||
"addEntry": "Ajouter une Activité",
|
||||
"editEntry": "Modifier un temps"
|
||||
"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."
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
@@ -146,7 +201,11 @@
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog",
|
||||
"createTask": "Créer une tâche"
|
||||
"createTask": "Créer une tâche",
|
||||
"sortBy": "Trier par",
|
||||
"sortDefault": "Par défaut",
|
||||
"sortDeadline": "Échéance",
|
||||
"sortScheduledStart": "Date planifiée"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -358,5 +417,35 @@
|
||||
"noResults": "Aucun résultat",
|
||||
"empty": "Aucun document lié"
|
||||
}
|
||||
},
|
||||
"zimbra": {
|
||||
"settings": {
|
||||
"title": "Calendrier Zimbra",
|
||||
"serverUrl": "URL du serveur CalDAV",
|
||||
"serverUrlPlaceholder": "https://mail.ovh.com",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernamePlaceholder": "user{'@'}domain.com",
|
||||
"calendarPath": "Chemin du calendrier",
|
||||
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
|
||||
"password": "Mot de passe",
|
||||
"passwordConfigured": "Mot de passe configuré",
|
||||
"enabled": "Activer la synchronisation CalDAV",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration Zimbra enregistrée",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie",
|
||||
"testFailed": "Connexion échouée"
|
||||
}
|
||||
},
|
||||
"taskRecurrence": {
|
||||
"created": "Récurrence créée",
|
||||
"updated": "Récurrence mise à jour",
|
||||
"deleted": "Récurrence supprimée"
|
||||
},
|
||||
"recurrence": {
|
||||
"daily": "Quotidien",
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
css: ['~/assets/css/dark.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
|
||||
101
frontend/package-lock.json
generated
101
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.1.0",
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -76,7 +76,6 @@
|
||||
"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",
|
||||
@@ -1038,6 +1037,7 @@
|
||||
"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,6 +1047,7 @@
|
||||
"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",
|
||||
@@ -1061,6 +1062,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -1073,6 +1075,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -1085,6 +1088,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1094,6 +1098,7 @@
|
||||
"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"
|
||||
@@ -1169,6 +1174,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1178,6 +1184,7 @@
|
||||
"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"
|
||||
@@ -1191,6 +1198,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -1204,6 +1212,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2203,9 +2212,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2483,7 +2492,6 @@
|
||||
"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",
|
||||
@@ -2556,7 +2564,6 @@
|
||||
"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",
|
||||
@@ -3203,7 +3210,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -5309,7 +5315,8 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -5321,7 +5328,8 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
@@ -5662,7 +5670,6 @@
|
||||
"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",
|
||||
@@ -5912,7 +5919,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -5952,6 +5958,7 @@
|
||||
"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",
|
||||
@@ -6283,7 +6290,6 @@
|
||||
"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": "*"
|
||||
},
|
||||
@@ -6477,7 +6483,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6606,7 +6611,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -6748,7 +6752,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -6785,7 +6788,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7341,7 +7343,8 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
@@ -7847,6 +7850,7 @@
|
||||
"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",
|
||||
@@ -7877,6 +7881,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7889,6 +7894,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7901,6 +7907,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7913,6 +7920,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -7922,6 +7930,7 @@
|
||||
"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",
|
||||
@@ -7939,6 +7948,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7964,6 +7974,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7976,6 +7987,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -8076,7 +8088,8 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -8104,13 +8117,15 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-npm-meta": {
|
||||
"version": "1.4.0",
|
||||
@@ -8155,6 +8170,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -8185,6 +8201,7 @@
|
||||
"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"
|
||||
@@ -8201,6 +8218,7 @@
|
||||
"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"
|
||||
@@ -8213,7 +8231,8 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
@@ -8746,6 +8765,7 @@
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
@@ -9122,19 +9142,22 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -9212,6 +9235,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9472,6 +9496,7 @@
|
||||
"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"
|
||||
@@ -9556,6 +9581,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -9947,7 +9973,8 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
@@ -10183,7 +10210,6 @@
|
||||
"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",
|
||||
@@ -10454,6 +10480,7 @@
|
||||
"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",
|
||||
@@ -10505,7 +10532,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10589,6 +10615,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10604,6 +10631,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10646,6 +10674,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -10749,7 +10778,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -10866,7 +10894,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11410,7 +11437,6 @@
|
||||
"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"
|
||||
@@ -11461,6 +11487,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -11497,6 +11524,7 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -11858,7 +11886,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -12641,7 +12668,6 @@
|
||||
"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",
|
||||
@@ -12982,6 +13008,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -13049,7 +13076,6 @@
|
||||
"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"
|
||||
@@ -13485,6 +13511,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -13509,7 +13536,6 @@
|
||||
"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",
|
||||
@@ -13871,7 +13897,6 @@
|
||||
"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",
|
||||
@@ -13936,7 +13961,6 @@
|
||||
"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",
|
||||
@@ -13958,7 +13982,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -14011,6 +14034,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -14179,6 +14203,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.1.0",
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,6 +47,7 @@ const tabs = [
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -30,13 +30,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Se connecter
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
@@ -48,6 +50,10 @@ const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||
const sortBy = ref<SortOption>('default')
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
@@ -155,6 +161,11 @@ async function loadTasks() {
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortBy.value === 'deadline') {
|
||||
params['order[deadline]'] = 'asc'
|
||||
} else if (sortBy.value === 'scheduledStart') {
|
||||
params['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
@@ -167,9 +178,9 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters to reload tasks
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
@@ -220,13 +231,23 @@ async function onDropBacklog(event: DragEvent) {
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
if (task.project?.code && task.number) {
|
||||
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskModalOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
@@ -277,8 +298,22 @@ async function onBulkDelete() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam) {
|
||||
const dashIndex = taskParam.lastIndexOf('-')
|
||||
if (dashIndex > 0) {
|
||||
const code = taskParam.slice(0, dashIndex)
|
||||
const num = Number(taskParam.slice(dashIndex + 1))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -289,15 +324,16 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</button>
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
@@ -364,6 +400,17 @@ onMounted(() => {
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -104,21 +104,19 @@
|
||||
:placeholder="$t('clientTicket.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelReject"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('clientTicket.status.rejected')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
>
|
||||
{{ $t('clientTicket.status.rejected') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,13 +74,12 @@
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<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"
|
||||
<MalioButton
|
||||
:label="$t('portal.submitTicket')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
{{ $t('portal.submitTicket') }}
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -26,15 +26,14 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
type="button"
|
||||
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="removing"
|
||||
:label="$t('profile.removeAvatar')"
|
||||
@click="onRemove"
|
||||
>
|
||||
{{ $t('profile.removeAvatar') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,20 +60,20 @@
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="onDelete(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
@@ -143,19 +143,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<span class="hidden sm:inline">Ajouter un ticket</span>
|
||||
<span class="sm:hidden">Ticket</span>
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
@@ -21,13 +23,12 @@
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
|
||||
title="Paramètres du projet"
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:cog-6-tooth"
|
||||
aria-label="Paramètres du projet"
|
||||
variant="ghost"
|
||||
@click="projectDrawerOpen = true"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,6 +225,7 @@ import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Projet' })
|
||||
@@ -349,13 +351,23 @@ async function loadData() {
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskDrawerOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
if (project.value?.code && task.number) {
|
||||
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskDrawerOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
@@ -445,7 +457,20 @@ async function onProjectSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam && project.value) {
|
||||
const prefix = `${project.value.code}-`
|
||||
if (taskParam.startsWith(prefix)) {
|
||||
const num = Number(taskParam.slice(prefix.length))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,23 +4,24 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
|
||||
:class="showArchived
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:icon-name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="toggleArchived"
|
||||
>
|
||||
<Icon :name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'" size="18" />
|
||||
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
|
||||
</button>
|
||||
<span class="hidden sm:inline">{{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,12 +45,13 @@
|
||||
{{ $t('common.archived') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-neutral-400 hover:text-primary-500"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier le projet"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openEdit(project)"
|
||||
>
|
||||
<Icon name="mdi:pencil-outline" size="16" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
||||
{{ project.description ?? '' }}
|
||||
|
||||
@@ -3,27 +3,35 @@
|
||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-primary-600 transition sm:px-4 sm:text-sm"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="shrink-0"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter une Activité</span>
|
||||
<span class="sm:hidden">+ Activité</span>
|
||||
</button>
|
||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||
<span class="sm:hidden">Activité</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
@click="navigatePrev"
|
||||
/>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
@click="navigateNext"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
@@ -75,6 +83,15 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
@click="exportDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +137,15 @@
|
||||
@paste="onPaste"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
|
||||
<TimeTrackingExportDrawer
|
||||
v-model="exportDrawerOpen"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:tags="tags"
|
||||
:clients="clients"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +154,7 @@ 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'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
@@ -148,6 +175,8 @@ const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const exportDrawerOpen = ref(false)
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editingEntry = ref<TimeEntry | null>(null)
|
||||
@@ -297,6 +326,37 @@ async function onDelete(entry: TimeEntry) {
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function onExport(params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
|
||||
|
||||
toast.info({ message: t('timeEntries.exportLoading') })
|
||||
|
||||
try {
|
||||
const result = await timeEntryService.downloadExport(params)
|
||||
|
||||
const url = URL.createObjectURL(result.blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = result.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success({ message: t('timeEntries.exportSuccess') })
|
||||
} catch {
|
||||
toast.error({ message: t('timeEntries.exportError') })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
@@ -311,15 +371,17 @@ async function loadEntries() {
|
||||
async function loadReferenceData() {
|
||||
const api = useApi()
|
||||
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
api.get<HydraCollection<Client>>('/clients'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
projects.value = extractHydraMembers(projectsData)
|
||||
tags.value = extractHydraMembers(typesData)
|
||||
clients.value = extractHydraMembers(clientsData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
22
frontend/services/dto/task-recurrence.ts
Normal file
22
frontend/services/dto/task-recurrence.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TaskRecurrence = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
}
|
||||
|
||||
export type TaskRecurrenceWrite = {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek?: string[] | null
|
||||
dayOfMonth?: number | null
|
||||
weekOfMonth?: number | null
|
||||
endDate?: string | null
|
||||
maxOccurrences?: number | null
|
||||
}
|
||||
@@ -29,6 +29,23 @@ export type Task = {
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
syncToCalendar: boolean
|
||||
calendarSyncError: string | null
|
||||
recurrence: {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type TaskWrite = {
|
||||
@@ -43,4 +60,9 @@ export type TaskWrite = {
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
clientTicket?: string | null
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
syncToCalendar?: boolean
|
||||
recurrence?: string | null
|
||||
}
|
||||
|
||||
19
frontend/services/dto/zimbra.ts
Normal file
19
frontend/services/dto/zimbra.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type ZimbraSettings = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ZimbraSettingsWrite = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ZimbraTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
25
frontend/services/task-recurrences.ts
Normal file
25
frontend/services/task-recurrences.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TaskRecurrence, TaskRecurrenceWrite } from './dto/task-recurrence'
|
||||
|
||||
export function useTaskRecurrenceService() {
|
||||
const api = useApi()
|
||||
|
||||
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
|
||||
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskRecurrence.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
|
||||
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskRecurrence.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_recurrences/${id}`, {}, {
|
||||
toastSuccessKey: 'taskRecurrence.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { create, update, remove }
|
||||
}
|
||||
@@ -50,5 +50,45 @@ export function useTimeEntryService() {
|
||||
})
|
||||
}
|
||||
|
||||
return { getByDateRange, getActive, create, update, remove }
|
||||
function getExportUrl(params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}): string {
|
||||
const query = new URLSearchParams()
|
||||
query.set('after', params.after)
|
||||
query.set('before', params.before)
|
||||
if (params.users?.length) {
|
||||
params.users.forEach(id => query.append('users[]', String(id)))
|
||||
}
|
||||
if (params.client) query.set('client', String(params.client))
|
||||
if (params.projects?.length) {
|
||||
params.projects.forEach(id => query.append('projects[]', String(id)))
|
||||
}
|
||||
if (params.tags?.length) {
|
||||
params.tags.forEach(id => query.append('tags[]', String(id)))
|
||||
}
|
||||
return `/time_entries/export?${query.toString()}`
|
||||
}
|
||||
|
||||
async function downloadExport(params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}): Promise<{ blob: Blob; filename: string }> {
|
||||
const url = getExportUrl(params)
|
||||
const response = await api.getBlob(url)
|
||||
const disposition = response.headers.get('content-disposition') ?? ''
|
||||
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
|
||||
const filename = filenameMatch?.[1] ?? `export-temps-${params.after}_${params.before}.xlsx`
|
||||
return { blob: response.data, filename }
|
||||
}
|
||||
|
||||
return { getByDateRange, getActive, create, update, remove, getExportUrl, downloadExport }
|
||||
}
|
||||
|
||||
21
frontend/services/zimbra.ts
Normal file
21
frontend/services/zimbra.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ZimbraSettings, ZimbraSettingsWrite, ZimbraTestResult } from './dto/zimbra'
|
||||
|
||||
export function useZimbraService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<ZimbraSettings> {
|
||||
return api.get<ZimbraSettings>('/settings/zimbra')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
|
||||
return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'zimbra.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<ZimbraTestResult> {
|
||||
return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
|
||||
}
|
||||
|
||||
return { getSettings, saveSettings, testConnection }
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
||||
if (saved !== null) {
|
||||
sidebarCollapsed.value = saved === 'true'
|
||||
}
|
||||
|
||||
const savedDark = localStorage.getItem('ui-dark-mode')
|
||||
if (savedDark !== null) {
|
||||
darkMode.value = savedDark === 'true'
|
||||
}
|
||||
applyDarkClass(darkMode.value)
|
||||
}
|
||||
|
||||
watch(sidebarCollapsed, (val) => {
|
||||
@@ -15,6 +22,25 @@ export const useUiStore = defineStore('ui', () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(darkMode, (val) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('ui-dark-mode', String(val))
|
||||
applyDarkClass(val)
|
||||
}
|
||||
})
|
||||
|
||||
function applyDarkClass(dark: boolean) {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
@@ -27,5 +53,5 @@ export const useUiStore = defineStore('ui', () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, sidebarOpen, toggleSidebar, openMobileSidebar, closeMobileSidebar }
|
||||
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
@@ -18,6 +19,28 @@ export default <Partial<Config>>{
|
||||
},
|
||||
blue: {
|
||||
500: '#056CF2'
|
||||
},
|
||||
m: {
|
||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--m-muted) / <alpha-value>)',
|
||||
bg: 'rgb(var(--m-bg) / <alpha-value>)',
|
||||
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
|
||||
danger: 'rgb(var(--m-danger) / <alpha-value>)',
|
||||
success: 'rgb(var(--m-success) / <alpha-value>)',
|
||||
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
|
||||
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
|
||||
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
|
||||
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
|
||||
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
|
||||
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
|
||||
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
|
||||
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
|
||||
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
migrations/Version20260319090835.php
Normal file
53
migrations/Version20260319090835.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260319090835 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE task_recurrence (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(255) NOT NULL, interval INT NOT NULL, days_of_week JSON DEFAULT NULL, day_of_month INT DEFAULT NULL, week_of_month INT DEFAULT NULL, end_date DATE DEFAULT NULL, max_occurrences INT DEFAULT NULL, occurrence_count INT NOT NULL, version INT DEFAULT 1 NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE TABLE zimbra_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, server_url VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, calendar_path VARCHAR(255) DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('ALTER TABLE task ADD scheduled_start TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD scheduled_end TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD deadline TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD sync_to_calendar BOOLEAN DEFAULT false NOT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD calendar_event_uid VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD calendar_todo_uid VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD calendar_sync_error TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD recurrence_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB252C414CE8 FOREIGN KEY (recurrence_id) REFERENCES task_recurrence (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_527EDB252C414CE8 ON task (recurrence_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE task_recurrence');
|
||||
$this->addSql('DROP TABLE zimbra_configuration');
|
||||
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB252C414CE8');
|
||||
$this->addSql('DROP INDEX IDX_527EDB252C414CE8');
|
||||
$this->addSql('ALTER TABLE task DROP scheduled_start');
|
||||
$this->addSql('ALTER TABLE task DROP scheduled_end');
|
||||
$this->addSql('ALTER TABLE task DROP deadline');
|
||||
$this->addSql('ALTER TABLE task DROP sync_to_calendar');
|
||||
$this->addSql('ALTER TABLE task DROP calendar_event_uid');
|
||||
$this->addSql('ALTER TABLE task DROP calendar_todo_uid');
|
||||
$this->addSql('ALTER TABLE task DROP calendar_sync_error');
|
||||
$this->addSql('ALTER TABLE task DROP recurrence_id');
|
||||
}
|
||||
}
|
||||
51
src/ApiResource/ZimbraSettings.php
Normal file
51
src/ApiResource/ZimbraSettings.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\State\ZimbraSettingsProcessor;
|
||||
use App\State\ZimbraSettingsProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/settings/zimbra',
|
||||
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
||||
provider: ZimbraSettingsProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/settings/zimbra',
|
||||
denormalizationContext: ['groups' => ['zimbra_settings:write']],
|
||||
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
||||
provider: ZimbraSettingsProvider::class,
|
||||
processor: ZimbraSettingsProcessor::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ZimbraSettings
|
||||
{
|
||||
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||
public ?string $serverUrl = null;
|
||||
|
||||
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||
public ?string $username = null;
|
||||
|
||||
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||
public ?string $calendarPath = null;
|
||||
|
||||
#[Groups(['zimbra_settings:write'])]
|
||||
public ?string $password = null;
|
||||
|
||||
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
||||
public bool $enabled = false;
|
||||
|
||||
#[Groups(['zimbra_settings:read'])]
|
||||
public bool $hasPassword = false;
|
||||
}
|
||||
28
src/ApiResource/ZimbraTestConnection.php
Normal file
28
src/ApiResource/ZimbraTestConnection.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\ZimbraTestConnectionProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/settings/zimbra/test',
|
||||
input: false,
|
||||
normalizationContext: ['groups' => ['zimbra_test:read']],
|
||||
provider: ZimbraTestConnectionProvider::class,
|
||||
processor: ZimbraTestConnectionProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ZimbraTestConnection
|
||||
{
|
||||
#[Groups(['zimbra_test:read'])]
|
||||
public bool $success = false;
|
||||
}
|
||||
136
src/Controller/TimeEntryExportController.php
Normal file
136
src/Controller/TimeEntryExportController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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.');
|
||||
}
|
||||
|
||||
if ($after->modify('+12 months') < $before) {
|
||||
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
$users = null;
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
/** @var User $currentUser */
|
||||
$currentUser = $this->security->getUser();
|
||||
$users = [$currentUser];
|
||||
} else {
|
||||
/** @var int[] $userIds */
|
||||
$userIds = array_filter(
|
||||
array_map('intval', (array) $request->query->all('users')),
|
||||
fn (int $id) => $id > 0,
|
||||
);
|
||||
if ([] !== $userIds) {
|
||||
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client (filter projects by client) ---
|
||||
$clientId = $request->query->getInt('client');
|
||||
$clientProjects = null;
|
||||
if ($clientId > 0) {
|
||||
$clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]);
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
$projects = null;
|
||||
|
||||
/** @var int[] $projectIds */
|
||||
$projectIds = array_filter(
|
||||
array_map('intval', (array) $request->query->all('projects')),
|
||||
fn (int $id) => $id > 0,
|
||||
);
|
||||
if ([] !== $projectIds) {
|
||||
$projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]);
|
||||
}
|
||||
|
||||
// Merge: if both client and projects are set, intersect; if only client, use client projects
|
||||
if (null !== $clientProjects && null !== $projects) {
|
||||
$clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects);
|
||||
$projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true)));
|
||||
if ([] === $projects) {
|
||||
$projects = null;
|
||||
// No matching projects — force empty result by using a dummy condition
|
||||
$entries = [];
|
||||
$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;
|
||||
}
|
||||
} elseif (null !== $clientProjects) {
|
||||
$projects = $clientProjects;
|
||||
}
|
||||
|
||||
/** @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,
|
||||
$users ?: null,
|
||||
$projects ?: null,
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,13 @@ use App\Entity\Task;
|
||||
use App\Entity\TaskEffort;
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Entity\TaskPriority;
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\User;
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use App\Enum\RecurrenceType;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
@@ -274,6 +277,9 @@ class AppFixtures extends Fixture
|
||||
$task2->setGroup($groupFrontend);
|
||||
$task2->setProject($projectSirh);
|
||||
$task2->addTag($tagAuth);
|
||||
$task2->setScheduledStart(new DateTimeImmutable('next monday 09:00'));
|
||||
$task2->setScheduledEnd(new DateTimeImmutable('next monday 17:00'));
|
||||
$task2->setSyncToCalendar(false);
|
||||
$manager->persist($task2);
|
||||
|
||||
$task3 = new Task();
|
||||
@@ -308,6 +314,8 @@ class AppFixtures extends Fixture
|
||||
$task5->setAssignee($userCharlie);
|
||||
$task5->setProject($projectSirh);
|
||||
$task5->addTag($tagCalendar);
|
||||
$task5->setDeadline(new DateTimeImmutable('+2 weeks'));
|
||||
$task5->setSyncToCalendar(false);
|
||||
$manager->persist($task5);
|
||||
|
||||
$task6 = new Task();
|
||||
@@ -414,6 +422,8 @@ class AppFixtures extends Fixture
|
||||
$taskErp3->setAssignee($admin);
|
||||
$taskErp3->setGroup($groupErpFacturation);
|
||||
$taskErp3->setProject($projectErp);
|
||||
$taskErp3->setDeadline(new DateTimeImmutable('+1 month'));
|
||||
$taskErp3->setSyncToCalendar(false);
|
||||
$manager->persist($taskErp3);
|
||||
|
||||
$taskErp4 = new Task();
|
||||
@@ -650,6 +660,39 @@ class AppFixtures extends Fixture
|
||||
// Link a task to a client ticket
|
||||
$task3->setClientTicket($ticket1);
|
||||
|
||||
// =============================================
|
||||
// Zimbra Configuration
|
||||
// =============================================
|
||||
$zimbraConfig = new ZimbraConfiguration();
|
||||
$zimbraConfig->setServerUrl('https://mail.ovh.com');
|
||||
$zimbraConfig->setUsername('lesstime@ovh.fr');
|
||||
$zimbraConfig->setCalendarPath('/dav/lesstime@ovh.fr/Calendar/');
|
||||
$zimbraConfig->setEnabled(false);
|
||||
$manager->persist($zimbraConfig);
|
||||
|
||||
// =============================================
|
||||
// Task Recurrence — exemple hebdomadaire
|
||||
// =============================================
|
||||
$recurrence = new TaskRecurrence();
|
||||
$recurrence->setType(RecurrenceType::Weekly);
|
||||
$recurrence->setInterval(1);
|
||||
$recurrence->setDaysOfWeek(['monday', 'wednesday', 'friday']);
|
||||
$manager->persist($recurrence);
|
||||
|
||||
$taskRecurring = new Task();
|
||||
$taskRecurring->setNumber(7);
|
||||
$taskRecurring->setTitle('Réunion de suivi hebdomadaire');
|
||||
$taskRecurring->setStatus($statusTodo);
|
||||
$taskRecurring->setEffort($effortS);
|
||||
$taskRecurring->setPriority($priorityMedium);
|
||||
$taskRecurring->setAssignee($admin);
|
||||
$taskRecurring->setProject($projectSirh);
|
||||
$taskRecurring->setScheduledStart(new DateTimeImmutable('next monday 10:00'));
|
||||
$taskRecurring->setScheduledEnd(new DateTimeImmutable('next monday 10:30'));
|
||||
$taskRecurring->setSyncToCalendar(false);
|
||||
$taskRecurring->setRecurrence($recurrence);
|
||||
$manager->persist($taskRecurring);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ class Client
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client:read', 'project:read'])]
|
||||
#[Groups(['client:read', 'project:read', 'user:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['client:read', 'client:write', 'project:read'])]
|
||||
#[Groups(['client:read', 'client:write', 'project:read', 'user:list'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
|
||||
@@ -43,7 +43,7 @@ class Project
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read'])]
|
||||
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 10, unique: true)]
|
||||
@@ -53,7 +53,7 @@ class Project
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read'])]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -14,26 +16,32 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\State\TaskCalendarProcessor;
|
||||
use App\State\TaskNumberProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task:read']],
|
||||
denormalizationContext: ['groups' => ['task:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||
#[ORM\Table(name: 'task')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
|
||||
@@ -111,6 +119,37 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?ClientTicket $clientTicket = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?DateTimeImmutable $scheduledStart = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?DateTimeImmutable $scheduledEnd = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?DateTimeImmutable $deadline = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private bool $syncToCalendar = false;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $calendarEventUid = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $calendarTodoUid = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['task:read'])]
|
||||
private ?string $calendarSyncError = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?TaskRecurrence $recurrence = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
@@ -281,4 +320,118 @@ class Task
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScheduledStart(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->scheduledStart;
|
||||
}
|
||||
|
||||
public function setScheduledStart(?DateTimeImmutable $scheduledStart): static
|
||||
{
|
||||
$this->scheduledStart = $scheduledStart;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScheduledEnd(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->scheduledEnd;
|
||||
}
|
||||
|
||||
public function setScheduledEnd(?DateTimeImmutable $scheduledEnd): static
|
||||
{
|
||||
$this->scheduledEnd = $scheduledEnd;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeadline(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deadline;
|
||||
}
|
||||
|
||||
public function setDeadline(?DateTimeImmutable $deadline): static
|
||||
{
|
||||
$this->deadline = $deadline;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSyncToCalendar(): bool
|
||||
{
|
||||
return $this->syncToCalendar;
|
||||
}
|
||||
|
||||
public function setSyncToCalendar(bool $syncToCalendar): static
|
||||
{
|
||||
$this->syncToCalendar = $syncToCalendar;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalendarEventUid(): ?string
|
||||
{
|
||||
return $this->calendarEventUid;
|
||||
}
|
||||
|
||||
public function setCalendarEventUid(?string $calendarEventUid): static
|
||||
{
|
||||
$this->calendarEventUid = $calendarEventUid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalendarTodoUid(): ?string
|
||||
{
|
||||
return $this->calendarTodoUid;
|
||||
}
|
||||
|
||||
public function setCalendarTodoUid(?string $calendarTodoUid): static
|
||||
{
|
||||
$this->calendarTodoUid = $calendarTodoUid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalendarSyncError(): ?string
|
||||
{
|
||||
return $this->calendarSyncError;
|
||||
}
|
||||
|
||||
public function setCalendarSyncError(?string $calendarSyncError): static
|
||||
{
|
||||
$this->calendarSyncError = $calendarSyncError;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRecurrence(): ?TaskRecurrence
|
||||
{
|
||||
return $this->recurrence;
|
||||
}
|
||||
|
||||
public function setRecurrence(?TaskRecurrence $recurrence): static
|
||||
{
|
||||
$this->recurrence = $recurrence;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateScheduledDates(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ((null === $this->scheduledStart) !== (null === $this->scheduledEnd)) {
|
||||
$context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.')
|
||||
->atPath('scheduledEnd')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
if (null !== $this->scheduledStart && null !== $this->scheduledEnd
|
||||
&& $this->scheduledEnd <= $this->scheduledStart) {
|
||||
$context->buildViolation('scheduledEnd must be after scheduledStart.')
|
||||
->atPath('scheduledEnd')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,21 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\EventListener\TaskDocumentListener;
|
||||
use App\State\TaskDocumentProcessor;
|
||||
use App\State\TaskDocumentProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
|
||||
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
|
||||
processor: TaskDocumentProcessor::class,
|
||||
deserialize: false,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_document:read']],
|
||||
denormalizationContext: ['groups' => ['task_document:write']],
|
||||
|
||||
197
src/Entity/TaskRecurrence.php
Normal file
197
src/Entity/TaskRecurrence.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Repository\TaskRecurrenceRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_recurrence:read']],
|
||||
denormalizationContext: ['groups' => ['task_recurrence:write']],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: TaskRecurrenceRepository::class)]
|
||||
class TaskRecurrence
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_recurrence:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', enumType: RecurrenceType::class)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?RecurrenceType $type = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private int $interval = 1;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?array $daysOfWeek = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?int $dayOfMonth = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?int $weekOfMonth = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?DateTimeImmutable $endDate = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
||||
private ?int $maxOccurrences = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['task_recurrence:read'])]
|
||||
private int $occurrenceCount = 0;
|
||||
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $version = 1;
|
||||
|
||||
/** @var Collection<int, Task> */
|
||||
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
|
||||
private Collection $tasks;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tasks = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): ?RecurrenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(RecurrenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInterval(): int
|
||||
{
|
||||
return $this->interval;
|
||||
}
|
||||
|
||||
public function setInterval(int $interval): static
|
||||
{
|
||||
$this->interval = $interval;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDaysOfWeek(): ?array
|
||||
{
|
||||
return $this->daysOfWeek;
|
||||
}
|
||||
|
||||
public function setDaysOfWeek(?array $daysOfWeek): static
|
||||
{
|
||||
$this->daysOfWeek = $daysOfWeek;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDayOfMonth(): ?int
|
||||
{
|
||||
return $this->dayOfMonth;
|
||||
}
|
||||
|
||||
public function setDayOfMonth(?int $dayOfMonth): static
|
||||
{
|
||||
$this->dayOfMonth = $dayOfMonth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWeekOfMonth(): ?int
|
||||
{
|
||||
return $this->weekOfMonth;
|
||||
}
|
||||
|
||||
public function setWeekOfMonth(?int $weekOfMonth): static
|
||||
{
|
||||
$this->weekOfMonth = $weekOfMonth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(?DateTimeImmutable $endDate): static
|
||||
{
|
||||
$this->endDate = $endDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxOccurrences(): ?int
|
||||
{
|
||||
return $this->maxOccurrences;
|
||||
}
|
||||
|
||||
public function setMaxOccurrences(?int $maxOccurrences): static
|
||||
{
|
||||
$this->maxOccurrences = $maxOccurrences;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOccurrenceCount(): int
|
||||
{
|
||||
return $this->occurrenceCount;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Task> */
|
||||
public function getTasks(): Collection
|
||||
{
|
||||
return $this->tasks;
|
||||
}
|
||||
|
||||
public function incrementOccurrenceCount(): static
|
||||
{
|
||||
++$this->occurrenceCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
104
src/Entity/ZimbraConfiguration.php
Normal file
104
src/Entity/ZimbraConfiguration.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ZimbraConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ZimbraConfigurationRepository::class)]
|
||||
class ZimbraConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Url]
|
||||
private ?string $serverUrl = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedPassword = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $calendarPath = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $enabled = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getServerUrl(): ?string
|
||||
{
|
||||
return $this->serverUrl;
|
||||
}
|
||||
|
||||
public function setServerUrl(?string $serverUrl): static
|
||||
{
|
||||
$this->serverUrl = $serverUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(?string $username): static
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedPassword(): ?string
|
||||
{
|
||||
return $this->encryptedPassword;
|
||||
}
|
||||
|
||||
public function setEncryptedPassword(?string $encryptedPassword): static
|
||||
{
|
||||
$this->encryptedPassword = $encryptedPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCalendarPath(): ?string
|
||||
{
|
||||
return $this->calendarPath;
|
||||
}
|
||||
|
||||
public function setCalendarPath(?string $calendarPath): static
|
||||
{
|
||||
$this->calendarPath = $calendarPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return null !== $this->encryptedPassword;
|
||||
}
|
||||
}
|
||||
13
src/Enum/RecurrenceType.php
Normal file
13
src/Enum/RecurrenceType.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum RecurrenceType: string
|
||||
{
|
||||
case Daily = 'daily';
|
||||
case Weekly = 'weekly';
|
||||
case Monthly = 'monthly';
|
||||
case Yearly = 'yearly';
|
||||
}
|
||||
90
src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php
Normal file
90
src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Service\CalDavService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array (e.g. ["monday","wednesday"]). For monthly, provide dayOfMonth OR weekOfMonth.')]
|
||||
class CreateTaskRecurrenceTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $taskId,
|
||||
string $type,
|
||||
int $interval = 1,
|
||||
?array $daysOfWeek = null,
|
||||
?int $dayOfMonth = null,
|
||||
?int $weekOfMonth = null,
|
||||
?string $endDate = null,
|
||||
?int $maxOccurrences = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$task = $this->taskRepository->find($taskId);
|
||||
if (null === $task) {
|
||||
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
|
||||
}
|
||||
|
||||
$recurrenceType = RecurrenceType::from($type);
|
||||
|
||||
$recurrence = new TaskRecurrence();
|
||||
$recurrence->setType($recurrenceType);
|
||||
$recurrence->setInterval($interval);
|
||||
|
||||
if (null !== $daysOfWeek) {
|
||||
$recurrence->setDaysOfWeek($daysOfWeek);
|
||||
}
|
||||
if (null !== $dayOfMonth) {
|
||||
$recurrence->setDayOfMonth($dayOfMonth);
|
||||
}
|
||||
if (null !== $weekOfMonth) {
|
||||
$recurrence->setWeekOfMonth($weekOfMonth);
|
||||
}
|
||||
if (null !== $endDate) {
|
||||
$recurrence->setEndDate(new DateTimeImmutable($endDate));
|
||||
}
|
||||
if (null !== $maxOccurrences) {
|
||||
$recurrence->setMaxOccurrences($maxOccurrences);
|
||||
}
|
||||
|
||||
$task->setRecurrence($recurrence);
|
||||
$this->entityManager->persist($recurrence);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->calDavService->syncTask($task);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode([
|
||||
'id' => $recurrence->getId(),
|
||||
'type' => $recurrence->getType()?->value,
|
||||
'interval' => $recurrence->getInterval(),
|
||||
'daysOfWeek' => $recurrence->getDaysOfWeek(),
|
||||
'dayOfMonth' => $recurrence->getDayOfMonth(),
|
||||
'weekOfMonth' => $recurrence->getWeekOfMonth(),
|
||||
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
|
||||
'maxOccurrences' => $recurrence->getMaxOccurrences(),
|
||||
'taskId' => $task->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\CalDavService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -36,6 +38,7 @@ class CreateTaskTool
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -48,6 +51,10 @@ class CreateTaskTool
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
?string $deadline = null,
|
||||
?bool $syncToCalendar = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
@@ -109,6 +116,18 @@ class CreateTaskTool
|
||||
$task->addTag($tag);
|
||||
}
|
||||
}
|
||||
if (null !== $scheduledStart) {
|
||||
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||
}
|
||||
if (null !== $scheduledEnd) {
|
||||
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
|
||||
}
|
||||
if (null !== $deadline) {
|
||||
$task->setDeadline(new DateTimeImmutable($deadline));
|
||||
}
|
||||
if (null !== $syncToCalendar) {
|
||||
$task->setSyncToCalendar($syncToCalendar);
|
||||
}
|
||||
|
||||
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
|
||||
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
||||
@@ -116,19 +135,26 @@ class CreateTaskTool
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
|
||||
$this->calDavService->syncTask($task);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode([
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($project),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($project),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'scheduledStart' => $task->getScheduledStart()?->format('c'),
|
||||
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
|
||||
'deadline' => $task->getDeadline()?->format('c'),
|
||||
'syncToCalendar' => $task->isSyncToCalendar(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php
Normal file
66
src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRecurrenceRepository;
|
||||
use App\Service\CalDavService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-task-recurrence', description: 'Delete a task recurrence pattern. Nullifies the recurrence on the active task and removes the recurring calendar event.')]
|
||||
class DeleteTaskRecurrenceTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $recurrenceId): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
|
||||
if (null === $recurrence) {
|
||||
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
|
||||
}
|
||||
|
||||
$tasks = $recurrence->getTasks()->toArray();
|
||||
|
||||
$eventUidToDelete = null;
|
||||
foreach ($tasks as $task) {
|
||||
if (null !== $task->getCalendarEventUid()) {
|
||||
$eventUidToDelete = $task->getCalendarEventUid();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$task->setRecurrence(null);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($recurrence);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (null !== $eventUidToDelete) {
|
||||
$this->calDavService->deleteEvent($eventUidToDelete);
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
'message' => sprintf('TaskRecurrence %d deleted.', $recurrenceId),
|
||||
'tasksUpdated' => count($tasks),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Service\CalDavService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -20,6 +21,7 @@ class DeleteTaskTool
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
@@ -35,9 +37,18 @@ class DeleteTaskTool
|
||||
}
|
||||
|
||||
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
|
||||
$eventUid = $task->getCalendarEventUid();
|
||||
$todoUid = $task->getCalendarTodoUid();
|
||||
$this->entityManager->remove($task);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (null !== $eventUid) {
|
||||
$this->calDavService->deleteEvent($eventUid);
|
||||
}
|
||||
if (null !== $todoUid) {
|
||||
$this->calDavService->deleteTodo($todoUid);
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
'message' => sprintf('Task %s deleted.', $taskCode),
|
||||
|
||||
88
src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php
Normal file
88
src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Repository\TaskRecurrenceRepository;
|
||||
use App\Service\CalDavService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-task-recurrence', description: 'Update an existing task recurrence pattern.')]
|
||||
class UpdateTaskRecurrenceTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $recurrenceId,
|
||||
?string $type = null,
|
||||
?int $interval = null,
|
||||
?array $daysOfWeek = null,
|
||||
?int $dayOfMonth = null,
|
||||
?int $weekOfMonth = null,
|
||||
?string $endDate = null,
|
||||
?int $maxOccurrences = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
|
||||
if (null === $recurrence) {
|
||||
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
|
||||
}
|
||||
|
||||
if (null !== $type) {
|
||||
$recurrence->setType(RecurrenceType::from($type));
|
||||
}
|
||||
if (null !== $interval) {
|
||||
$recurrence->setInterval($interval);
|
||||
}
|
||||
if (null !== $daysOfWeek) {
|
||||
$recurrence->setDaysOfWeek($daysOfWeek);
|
||||
}
|
||||
if (null !== $dayOfMonth) {
|
||||
$recurrence->setDayOfMonth($dayOfMonth);
|
||||
}
|
||||
if (null !== $weekOfMonth) {
|
||||
$recurrence->setWeekOfMonth($weekOfMonth);
|
||||
}
|
||||
if (null !== $endDate) {
|
||||
$recurrence->setEndDate(new DateTimeImmutable($endDate));
|
||||
}
|
||||
if (null !== $maxOccurrences) {
|
||||
$recurrence->setMaxOccurrences($maxOccurrences);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
foreach ($recurrence->getTasks() as $task) {
|
||||
$this->calDavService->syncTask($task);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode([
|
||||
'id' => $recurrence->getId(),
|
||||
'type' => $recurrence->getType()?->value,
|
||||
'interval' => $recurrence->getInterval(),
|
||||
'daysOfWeek' => $recurrence->getDaysOfWeek(),
|
||||
'dayOfMonth' => $recurrence->getDayOfMonth(),
|
||||
'weekOfMonth' => $recurrence->getWeekOfMonth(),
|
||||
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
|
||||
'maxOccurrences' => $recurrence->getMaxOccurrences(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\CalDavService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -33,6 +35,7 @@ class UpdateTaskTool
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
private readonly CalDavService $calDavService,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -46,6 +49,10 @@ class UpdateTaskTool
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?bool $archived = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
?string $deadline = null,
|
||||
?bool $syncToCalendar = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
@@ -114,22 +121,40 @@ class UpdateTaskTool
|
||||
if (null !== $archived) {
|
||||
$task->setArchived($archived);
|
||||
}
|
||||
if (null !== $scheduledStart) {
|
||||
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||
}
|
||||
if (null !== $scheduledEnd) {
|
||||
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
|
||||
}
|
||||
if (null !== $deadline) {
|
||||
$task->setDeadline(new DateTimeImmutable($deadline));
|
||||
}
|
||||
if (null !== $syncToCalendar) {
|
||||
$task->setSyncToCalendar($syncToCalendar);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->calDavService->syncTask($task);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode([
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'scheduledStart' => $task->getScheduledStart()?->format('c'),
|
||||
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
|
||||
'deadline' => $task->getDeadline()?->format('c'),
|
||||
'syncToCalendar' => $task->isSyncToCalendar(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
17
src/Repository/TaskRecurrenceRepository.php
Normal file
17
src/Repository/TaskRecurrenceRepository.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TaskRecurrence;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class TaskRecurrenceRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskRecurrence::class);
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,16 @@ class TaskStatusRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, TaskStatus::class);
|
||||
}
|
||||
|
||||
public function findFirstNonFinal(): ?TaskStatus
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.isFinal = :final')
|
||||
->setParameter('final', false)
|
||||
->orderBy('s.position', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,48 @@ class TimeEntryRepository extends ServiceEntityRepository
|
||||
'stoppedAt' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|User[] $users
|
||||
* @param null|Project[] $projects
|
||||
* @param null|int[] $tagIds
|
||||
*
|
||||
* @return TimeEntry[]
|
||||
*/
|
||||
public function findForExport(
|
||||
DateTimeImmutable $after,
|
||||
DateTimeImmutable $before,
|
||||
?array $users = null,
|
||||
?array $projects = 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 !== $users && [] !== $users) {
|
||||
$qb->andWhere('te.user IN (:users)')
|
||||
->setParameter('users', $users)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $projects && [] !== $projects) {
|
||||
$qb->andWhere('te.project IN (:projects)')
|
||||
->setParameter('projects', $projects)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $tagIds && [] !== $tagIds) {
|
||||
$qb->join('te.tags', 'tag')
|
||||
->andWhere('tag.id IN (:tagIds)')
|
||||
->setParameter('tagIds', $tagIds)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
26
src/Repository/ZimbraConfigurationRepository.php
Normal file
26
src/Repository/ZimbraConfigurationRepository.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ZimbraConfigurationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ZimbraConfiguration::class);
|
||||
}
|
||||
|
||||
public function findSingleton(): ?ZimbraConfiguration
|
||||
{
|
||||
return $this->createQueryBuilder('z')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
353
src/Service/CalDavService.php
Normal file
353
src/Service/CalDavService.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Repository\ZimbraConfigurationRepository;
|
||||
use DateTimeZone;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
final class CalDavService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ZimbraConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
return null !== $config && $config->isEnabled();
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config || !$config->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('PROPFIND', $this->getCalendarUrl(), [
|
||||
'timeout' => 5,
|
||||
'auth_basic' => [
|
||||
$config->getUsername(),
|
||||
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
|
||||
],
|
||||
'headers' => [
|
||||
'Depth' => '0',
|
||||
],
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('CalDAV connection test failed: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createEvent(Task $task): ?string
|
||||
{
|
||||
$uid = $this->generateUid();
|
||||
$calendar = $this->buildEventCalendar($task, $uid);
|
||||
|
||||
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $uid;
|
||||
}
|
||||
|
||||
public function createTodo(Task $task): ?string
|
||||
{
|
||||
$uid = $this->generateUid();
|
||||
$calendar = $this->buildTodoCalendar($task, $uid);
|
||||
|
||||
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $uid;
|
||||
}
|
||||
|
||||
public function updateEvent(Task $task): bool
|
||||
{
|
||||
$uid = $task->getCalendarEventUid();
|
||||
|
||||
if (null === $uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$calendar = $this->buildEventCalendar($task, $uid);
|
||||
|
||||
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
|
||||
}
|
||||
|
||||
public function updateTodo(Task $task): bool
|
||||
{
|
||||
$uid = $task->getCalendarTodoUid();
|
||||
|
||||
if (null === $uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$calendar = $this->buildTodoCalendar($task, $uid);
|
||||
|
||||
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
|
||||
}
|
||||
|
||||
public function deleteEvent(?string $uid): bool
|
||||
{
|
||||
if (null === $uid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
|
||||
}
|
||||
|
||||
public function deleteTodo(?string $uid): bool
|
||||
{
|
||||
if (null === $uid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
|
||||
}
|
||||
|
||||
public function syncTask(Task $task): void
|
||||
{
|
||||
if (!$task->isSyncToCalendar()) {
|
||||
$this->deleteEvent($task->getCalendarEventUid());
|
||||
$this->deleteTodo($task->getCalendarTodoUid());
|
||||
$task->setCalendarEventUid(null);
|
||||
$task->setCalendarTodoUid(null);
|
||||
$task->setCalendarSyncError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hasStart = null !== $task->getScheduledStart();
|
||||
$hasDeadline = null !== $task->getDeadline();
|
||||
|
||||
if (!$hasStart && !$hasDeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
$syncError = null;
|
||||
|
||||
if ($hasStart) {
|
||||
if (null !== $task->getCalendarEventUid()) {
|
||||
$success = $this->updateEvent($task);
|
||||
} else {
|
||||
$uid = $this->createEvent($task);
|
||||
if (null !== $uid) {
|
||||
$task->setCalendarEventUid($uid);
|
||||
$success = true;
|
||||
} else {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
$syncError = 'Failed to sync event to calendar.';
|
||||
}
|
||||
} elseif (null !== $task->getCalendarEventUid()) {
|
||||
$this->deleteEvent($task->getCalendarEventUid());
|
||||
$task->setCalendarEventUid(null);
|
||||
}
|
||||
|
||||
if ($hasDeadline) {
|
||||
if (null !== $task->getCalendarTodoUid()) {
|
||||
$success = $this->updateTodo($task);
|
||||
} else {
|
||||
$uid = $this->createTodo($task);
|
||||
if (null !== $uid) {
|
||||
$task->setCalendarTodoUid($uid);
|
||||
$success = true;
|
||||
} else {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
$syncError = ($syncError ?? '').'Failed to sync todo to calendar.';
|
||||
}
|
||||
} elseif (null !== $task->getCalendarTodoUid()) {
|
||||
$this->deleteTodo($task->getCalendarTodoUid());
|
||||
$task->setCalendarTodoUid(null);
|
||||
}
|
||||
|
||||
$task->setCalendarSyncError($syncError);
|
||||
}
|
||||
|
||||
private function buildEventCalendar(Task $task, string $uid): VCalendar
|
||||
{
|
||||
$project = $task->getProject();
|
||||
$projectCode = null !== $project ? $project->getCode() : '';
|
||||
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('VEVENT', [
|
||||
'UID' => $uid,
|
||||
'SUMMARY' => $summary,
|
||||
'DTSTART' => $task->getScheduledStart(),
|
||||
'DTEND' => $task->getScheduledEnd(),
|
||||
'DESCRIPTION' => $description,
|
||||
]);
|
||||
|
||||
$recurrence = $task->getRecurrence();
|
||||
|
||||
if (null !== $recurrence) {
|
||||
$vevent = $vcalendar->VEVENT;
|
||||
$vevent->add('RRULE', $this->buildRRule($recurrence));
|
||||
}
|
||||
|
||||
return $vcalendar;
|
||||
}
|
||||
|
||||
private function buildTodoCalendar(Task $task, string $uid): VCalendar
|
||||
{
|
||||
$project = $task->getProject();
|
||||
$projectCode = null !== $project ? $project->getCode() : '';
|
||||
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
||||
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('VTODO', [
|
||||
'UID' => $uid,
|
||||
'SUMMARY' => $summary,
|
||||
'DUE' => $task->getDeadline(),
|
||||
'DESCRIPTION' => $description,
|
||||
]);
|
||||
|
||||
return $vcalendar;
|
||||
}
|
||||
|
||||
private function buildRRule(TaskRecurrence $recurrence): string
|
||||
{
|
||||
$parts = [];
|
||||
$interval = $recurrence->getInterval();
|
||||
|
||||
match ($recurrence->getType()) {
|
||||
RecurrenceType::Daily => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
|
||||
RecurrenceType::Weekly => (function () use (&$parts, $interval, $recurrence): void {
|
||||
$dayMap = $this->getDayMap();
|
||||
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||
$byDay = implode(',', array_map(fn (string $d) => $dayMap[$d] ?? $d, $daysOfWeek));
|
||||
$rule = 'FREQ=WEEKLY;INTERVAL='.$interval;
|
||||
if ('' !== $byDay) {
|
||||
$rule .= ';BYDAY='.$byDay;
|
||||
}
|
||||
$parts[] = $rule;
|
||||
})(),
|
||||
RecurrenceType::Monthly => (function () use (&$parts, $interval, $recurrence): void {
|
||||
$dayOfMonth = $recurrence->getDayOfMonth();
|
||||
$weekOfMonth = $recurrence->getWeekOfMonth();
|
||||
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||
|
||||
if (null !== $dayOfMonth) {
|
||||
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYMONTHDAY='.$dayOfMonth;
|
||||
} elseif (null !== $weekOfMonth && [] !== $daysOfWeek) {
|
||||
$dayMap = $this->getDayMap();
|
||||
$day = $dayMap[$daysOfWeek[0]] ?? $daysOfWeek[0];
|
||||
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYDAY='.$weekOfMonth.$day;
|
||||
} else {
|
||||
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval;
|
||||
}
|
||||
})(),
|
||||
RecurrenceType::Yearly => $parts[] = 'FREQ=YEARLY;INTERVAL='.$interval,
|
||||
default => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
|
||||
};
|
||||
|
||||
$rule = $parts[0] ?? 'FREQ=DAILY;INTERVAL=1';
|
||||
|
||||
$endDate = $recurrence->getEndDate();
|
||||
$maxOccurrences = $recurrence->getMaxOccurrences();
|
||||
|
||||
if (null !== $endDate) {
|
||||
$rule .= ';UNTIL='.$endDate->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z');
|
||||
} elseif (null !== $maxOccurrences) {
|
||||
$rule .= ';COUNT='.$maxOccurrences;
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private function getCalendarUrl(): string
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim((string) $config->getServerUrl(), '/').'/'.ltrim((string) $config->getCalendarPath(), '/').'/';
|
||||
}
|
||||
|
||||
private function makeRequest(string $method, string $url, ?string $body = null, string $contentType = 'text/calendar'): bool
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'timeout' => 5,
|
||||
'auth_basic' => [
|
||||
$config->getUsername(),
|
||||
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
|
||||
],
|
||||
];
|
||||
|
||||
if (null !== $body) {
|
||||
$options['headers'] = ['Content-Type' => $contentType];
|
||||
$options['body'] = $body;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('CalDAV %s request to %s failed: %s', $method, $url, $e->getMessage()));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUid(): string
|
||||
{
|
||||
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function getDayMap(): array
|
||||
{
|
||||
return [
|
||||
'monday' => 'MO',
|
||||
'tuesday' => 'TU',
|
||||
'wednesday' => 'WE',
|
||||
'thursday' => 'TH',
|
||||
'friday' => 'FR',
|
||||
'saturday' => 'SA',
|
||||
'sunday' => 'SU',
|
||||
];
|
||||
}
|
||||
}
|
||||
250
src/Service/RecurrenceCalculator.php
Normal file
250
src/Service/RecurrenceCalculator.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Enum\RecurrenceType;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class RecurrenceCalculator
|
||||
{
|
||||
public function getNextDate(Task $task): ?DateTimeImmutable
|
||||
{
|
||||
$recurrence = $task->getRecurrence();
|
||||
$scheduledStart = $task->getScheduledStart();
|
||||
|
||||
if (null === $recurrence || null === $scheduledStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->hasReachedEnd($recurrence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $recurrence->getType();
|
||||
$interval = $recurrence->getInterval();
|
||||
|
||||
return match ($type) {
|
||||
RecurrenceType::Daily => $this->nextDaily($scheduledStart, $interval),
|
||||
RecurrenceType::Weekly => $this->nextWeekly($scheduledStart, $interval, $recurrence->getDaysOfWeek() ?? []),
|
||||
RecurrenceType::Monthly => $this->nextMonthly($scheduledStart, $interval, $recurrence),
|
||||
RecurrenceType::Yearly => $this->nextYearly($scheduledStart, $interval),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getNextEnd(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
|
||||
{
|
||||
$scheduledStart = $task->getScheduledStart();
|
||||
$scheduledEnd = $task->getScheduledEnd();
|
||||
|
||||
if (null === $scheduledEnd || null === $scheduledStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$duration = $scheduledStart->diff($scheduledEnd);
|
||||
|
||||
return $nextStart->add($duration);
|
||||
}
|
||||
|
||||
public function getNextDeadline(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
|
||||
{
|
||||
$scheduledStart = $task->getScheduledStart();
|
||||
$deadline = $task->getDeadline();
|
||||
|
||||
if (null === $deadline || null === $scheduledStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$offset = $scheduledStart->diff($deadline);
|
||||
|
||||
return $nextStart->add($offset);
|
||||
}
|
||||
|
||||
public function hasReachedEnd(TaskRecurrence $recurrence): bool
|
||||
{
|
||||
$maxOccurrences = $recurrence->getMaxOccurrences();
|
||||
|
||||
if (null !== $maxOccurrences && $recurrence->getOccurrenceCount() >= $maxOccurrences) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$endDate = $recurrence->getEndDate();
|
||||
|
||||
if (null !== $endDate) {
|
||||
$today = new DateTimeImmutable('today');
|
||||
|
||||
if ($endDate < $today) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function nextDaily(DateTimeImmutable $start, int $interval): DateTimeImmutable
|
||||
{
|
||||
return $start->modify(sprintf('+%d days', $interval));
|
||||
}
|
||||
|
||||
private function nextWeekly(DateTimeImmutable $start, int $interval, array $daysOfWeek): DateTimeImmutable
|
||||
{
|
||||
$candidate = $start->modify(sprintf('+%d weeks', $interval));
|
||||
|
||||
if ([] === $daysOfWeek) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$dayNumberMap = $this->getDayNumberMap();
|
||||
|
||||
// Collect target day numbers
|
||||
$targetDayNumbers = [];
|
||||
foreach ($daysOfWeek as $day) {
|
||||
if (isset($dayNumberMap[$day])) {
|
||||
$targetDayNumbers[] = $dayNumberMap[$day];
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $targetDayNumbers) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
sort($targetDayNumbers);
|
||||
|
||||
// Find the first matching day in the week starting from candidate
|
||||
$weekStart = (int) $candidate->format('N'); // 1=Mon, 7=Sun
|
||||
$candidateDayNum = $weekStart;
|
||||
|
||||
foreach ($targetDayNumbers as $targetDay) {
|
||||
if ($targetDay >= $candidateDayNum) {
|
||||
$diff = $targetDay - $candidateDayNum;
|
||||
|
||||
return $candidate->modify(sprintf('+%d days', $diff));
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap to next week's first matching day
|
||||
$diff = 7 - $candidateDayNum + $targetDayNumbers[0];
|
||||
|
||||
return $candidate->modify(sprintf('+%d days', $diff));
|
||||
}
|
||||
|
||||
private function nextMonthly(DateTimeImmutable $start, int $interval, TaskRecurrence $recurrence): DateTimeImmutable
|
||||
{
|
||||
$dayOfMonth = $recurrence->getDayOfMonth();
|
||||
$weekOfMonth = $recurrence->getWeekOfMonth();
|
||||
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
|
||||
|
||||
if (null !== $dayOfMonth) {
|
||||
return $this->nextMonthlyByDayOfMonth($start, $interval, $dayOfMonth);
|
||||
}
|
||||
|
||||
if (null !== $weekOfMonth && [] !== $daysOfWeek) {
|
||||
return $this->nextMonthlyByWeekOfMonth($start, $interval, $weekOfMonth, $daysOfWeek[0]);
|
||||
}
|
||||
|
||||
// Fallback: same day of month, interval months ahead
|
||||
return $this->nextMonthlyByDayOfMonth($start, $interval, (int) $start->format('j'));
|
||||
}
|
||||
|
||||
private function nextMonthlyByDayOfMonth(DateTimeImmutable $start, int $interval, int $dayOfMonth): DateTimeImmutable
|
||||
{
|
||||
$year = (int) $start->format('Y');
|
||||
$month = (int) $start->format('n');
|
||||
|
||||
$month += $interval;
|
||||
|
||||
while ($month > 12) {
|
||||
$month -= 12;
|
||||
++$year;
|
||||
}
|
||||
|
||||
// Handle month overflow (e.g. dayOfMonth=31 in a 30-day month)
|
||||
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
|
||||
$day = min($dayOfMonth, $daysInMonth);
|
||||
|
||||
return new DateTimeImmutable(sprintf(
|
||||
'%d-%02d-%02d %s',
|
||||
$year,
|
||||
$month,
|
||||
$day,
|
||||
$start->format('H:i:s'),
|
||||
));
|
||||
}
|
||||
|
||||
private function nextMonthlyByWeekOfMonth(DateTimeImmutable $start, int $interval, int $weekOfMonth, string $dayName): DateTimeImmutable
|
||||
{
|
||||
$year = (int) $start->format('Y');
|
||||
$month = (int) $start->format('n');
|
||||
|
||||
$month += $interval;
|
||||
|
||||
while ($month > 12) {
|
||||
$month -= 12;
|
||||
++$year;
|
||||
}
|
||||
|
||||
$dayNumberMap = $this->getDayNumberMap();
|
||||
$targetDayNum = $dayNumberMap[$dayName] ?? 1;
|
||||
|
||||
// Find the Nth occurrence of the target weekday in the target month
|
||||
$firstOfMonth = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
|
||||
$firstDayNum = (int) $firstOfMonth->format('N'); // 1=Mon, 7=Sun
|
||||
|
||||
// Days until first occurrence of target weekday
|
||||
$daysToFirst = ($targetDayNum - $firstDayNum + 7) % 7;
|
||||
$dayOfMonth = 1 + $daysToFirst + ($weekOfMonth - 1) * 7;
|
||||
|
||||
// Handle overflow (e.g. 5th occurrence that doesn't exist)
|
||||
$daysInMonth = (int) $firstOfMonth->format('t');
|
||||
|
||||
if ($dayOfMonth > $daysInMonth) {
|
||||
// Fall back to last occurrence
|
||||
$dayOfMonth -= 7;
|
||||
}
|
||||
|
||||
return new DateTimeImmutable(sprintf(
|
||||
'%d-%02d-%02d %s',
|
||||
$year,
|
||||
$month,
|
||||
$dayOfMonth,
|
||||
$start->format('H:i:s'),
|
||||
));
|
||||
}
|
||||
|
||||
private function nextYearly(DateTimeImmutable $start, int $interval): DateTimeImmutable
|
||||
{
|
||||
$year = (int) $start->format('Y') + $interval;
|
||||
$month = (int) $start->format('n');
|
||||
$day = (int) $start->format('j');
|
||||
|
||||
// Handle leap year: Feb 29 → Feb 28
|
||||
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
|
||||
$day = min($day, $daysInMonth);
|
||||
|
||||
return new DateTimeImmutable(sprintf(
|
||||
'%d-%02d-%02d %s',
|
||||
$year,
|
||||
$month,
|
||||
$day,
|
||||
$start->format('H:i:s'),
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
private function getDayNumberMap(): array
|
||||
{
|
||||
return [
|
||||
'monday' => 1,
|
||||
'tuesday' => 2,
|
||||
'wednesday' => 3,
|
||||
'thursday' => 4,
|
||||
'friday' => 5,
|
||||
'saturday' => 6,
|
||||
'sunday' => 7,
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user