Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7968af525 | ||
| df2a48c20d | |||
| 7f1c02256b | |||
| fdc9b8b60d | |||
| 1025fed0d1 | |||
| 0331d94ca5 | |||
| 755c39a0f6 | |||
| 8f8eeddd91 | |||
| 548b101d82 | |||
|
|
e3149f8a27 |
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
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.9'
|
app.version: '0.3.11'
|
||||||
|
|||||||
213
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
213
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<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"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export button -->
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||||
|
@click="doExport"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:download" size="18" />
|
||||||
|
{{ $t('timeEntries.export') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
users: UserData[]
|
||||||
|
projects: Project[]
|
||||||
|
tags: TaskTag[]
|
||||||
|
clients: Client[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = defineModel<boolean>({ default: false })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'export', params: {
|
||||||
|
after: string
|
||||||
|
before: string
|
||||||
|
users?: number[]
|
||||||
|
projects?: number[]
|
||||||
|
client?: number
|
||||||
|
tags?: number[]
|
||||||
|
}): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
|
||||||
|
const customFrom = ref('')
|
||||||
|
const customTo = ref('')
|
||||||
|
const selectedUserIds = ref<number[]>([])
|
||||||
|
const selectedClientId = ref<number | null>(null)
|
||||||
|
const selectedProjectIds = ref<number[]>([])
|
||||||
|
const selectedTagIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const clientOptions = computed(() =>
|
||||||
|
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredProjectOptions = computed(() => {
|
||||||
|
let list = props.projects
|
||||||
|
if (selectedClientId.value) {
|
||||||
|
list = list.filter(p => p.client?.id === selectedClientId.value)
|
||||||
|
}
|
||||||
|
return list.map(p => ({ label: p.name, value: p.id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagOptions = computed(() =>
|
||||||
|
props.tags.map(t => ({ label: t.label, value: t.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset project selection when client changes
|
||||||
|
watch(selectedClientId, () => {
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDateRange(): { after: string; before: string } {
|
||||||
|
const now = new Date()
|
||||||
|
if (periodMode.value === 'currentMonth') {
|
||||||
|
const first = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||||
|
return {
|
||||||
|
after: first.toISOString().slice(0, 10),
|
||||||
|
before: last.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (periodMode.value === 'lastMonth') {
|
||||||
|
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||||
|
const last = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
return {
|
||||||
|
after: first.toISOString().slice(0, 10),
|
||||||
|
before: last.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
after: customFrom.value,
|
||||||
|
before: customTo.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doExport() {
|
||||||
|
const { after, before } = getDateRange()
|
||||||
|
if (!after || !before) return
|
||||||
|
|
||||||
|
emit('export', {
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
|
||||||
|
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
|
||||||
|
client: selectedClientId.value ?? undefined,
|
||||||
|
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
|
||||||
|
})
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -162,7 +162,21 @@
|
|||||||
"noEntries": "Aucune activité pour cette période",
|
"noEntries": "Aucune activité pour cette période",
|
||||||
"addEntry": "Ajouter une Activité",
|
"addEntry": "Ajouter une Activité",
|
||||||
"editEntry": "Modifier un temps",
|
"editEntry": "Modifier un temps",
|
||||||
"export": "Exporter"
|
"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": {
|
"archive": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<button
|
<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"
|
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"
|
@click="exportDrawerOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:download" size="18" />
|
<Icon name="mdi:download" size="18" />
|
||||||
{{ $t('timeEntries.export') }}
|
{{ $t('timeEntries.export') }}
|
||||||
@@ -128,6 +128,15 @@
|
|||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TimeTrackingExportDrawer
|
||||||
|
v-model="exportDrawerOpen"
|
||||||
|
:users="users"
|
||||||
|
:projects="projects"
|
||||||
|
:tags="tags"
|
||||||
|
:clients="clients"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,6 +145,7 @@ import type { TimeEntry } from '~/services/dto/time-entry'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useTimeEntryService } from '~/services/time-entries'
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
import type { HydraCollection } from '~/utils/api'
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
@@ -156,6 +166,8 @@ const entries = ref<TimeEntry[]>([])
|
|||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
const exportDrawerOpen = ref(false)
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const editingEntry = ref<TimeEntry | null>(null)
|
const editingEntry = ref<TimeEntry | null>(null)
|
||||||
@@ -305,38 +317,35 @@ async function onDelete(entry: TimeEntry) {
|
|||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExportDateRange(): { after: string, before: string } {
|
async function onExport(params: {
|
||||||
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
|
after: string
|
||||||
return {
|
before: string
|
||||||
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
|
users?: number[]
|
||||||
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
|
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') })
|
||||||
}
|
}
|
||||||
const end = new Date(startDate.value)
|
|
||||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
|
||||||
return {
|
|
||||||
after: startDate.value.toISOString().slice(0, 10),
|
|
||||||
before: end.toISOString().slice(0, 10),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportTimeEntries() {
|
|
||||||
const { after, before } = getExportDateRange()
|
|
||||||
|
|
||||||
const url = timeEntryService.getExportUrl({
|
|
||||||
after,
|
|
||||||
before,
|
|
||||||
user: selectedUserId.value ?? undefined,
|
|
||||||
project: selectedProjectId.value ?? undefined,
|
|
||||||
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = ''
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
@@ -353,15 +362,17 @@ async function loadEntries() {
|
|||||||
async function loadReferenceData() {
|
async function loadReferenceData() {
|
||||||
const api = useApi()
|
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<UserData>>('/users'),
|
||||||
api.get<HydraCollection<Project>>('/projects'),
|
api.get<HydraCollection<Project>>('/projects'),
|
||||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||||
|
api.get<HydraCollection<Client>>('/clients'),
|
||||||
])
|
])
|
||||||
|
|
||||||
users.value = extractHydraMembers(usersData)
|
users.value = extractHydraMembers(usersData)
|
||||||
projects.value = extractHydraMembers(projectsData)
|
projects.value = extractHydraMembers(projectsData)
|
||||||
tags.value = extractHydraMembers(typesData)
|
tags.value = extractHydraMembers(typesData)
|
||||||
|
clients.value = extractHydraMembers(clientsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -53,20 +53,42 @@ export function useTimeEntryService() {
|
|||||||
function getExportUrl(params: {
|
function getExportUrl(params: {
|
||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
users?: number[]
|
||||||
project?: number
|
projects?: number[]
|
||||||
|
client?: number
|
||||||
tags?: number[]
|
tags?: number[]
|
||||||
}): string {
|
}): string {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
query.set('after', params.after)
|
query.set('after', params.after)
|
||||||
query.set('before', params.before)
|
query.set('before', params.before)
|
||||||
if (params.user) query.set('user', String(params.user))
|
if (params.users?.length) {
|
||||||
if (params.project) query.set('project', String(params.project))
|
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) {
|
if (params.tags?.length) {
|
||||||
params.tags.forEach(id => query.append('tags[]', String(id)))
|
params.tags.forEach(id => query.append('tags[]', String(id)))
|
||||||
}
|
}
|
||||||
return `/api/time_entries/export?${query.toString()}`
|
return `/time_entries/export?${query.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getByDateRange, getActive, create, update, remove, getExportUrl }
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,27 +47,65 @@ class TimeEntryExportController extends AbstractController
|
|||||||
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
|
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max range: 12 months
|
|
||||||
if ($after->modify('+12 months') < $before) {
|
if ($after->modify('+12 months') < $before) {
|
||||||
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: non-admin users can only export their own data
|
// --- Users ---
|
||||||
$user = null;
|
$users = null;
|
||||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
/** @var User $user */
|
/** @var User $currentUser */
|
||||||
$user = $this->security->getUser();
|
$currentUser = $this->security->getUser();
|
||||||
|
$users = [$currentUser];
|
||||||
} else {
|
} else {
|
||||||
$userId = $request->query->getInt('user');
|
/** @var int[] $userIds */
|
||||||
if ($userId > 0) {
|
$userIds = array_filter(
|
||||||
$user = $this->entityManager->getRepository(User::class)->find($userId);
|
array_map('intval', (array) $request->query->all('users')),
|
||||||
|
fn (int $id) => $id > 0,
|
||||||
|
);
|
||||||
|
if ([] !== $userIds) {
|
||||||
|
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = null;
|
// --- Client (filter projects by client) ---
|
||||||
$projectId = $request->query->getInt('project');
|
$clientId = $request->query->getInt('client');
|
||||||
if ($projectId > 0) {
|
$clientProjects = null;
|
||||||
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
|
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 */
|
/** @var int[] $tagIds */
|
||||||
@@ -79,8 +117,8 @@ class TimeEntryExportController extends AbstractController
|
|||||||
$entries = $this->timeEntryRepository->findForExport(
|
$entries = $this->timeEntryRepository->findForExport(
|
||||||
$after,
|
$after,
|
||||||
$before,
|
$before,
|
||||||
$user,
|
$users ?: null,
|
||||||
$project,
|
$projects ?: null,
|
||||||
$tagIds ?: null,
|
$tagIds ?: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,17 @@ class TimeEntryRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param null|int[] $tagIds
|
* @param null|User[] $users
|
||||||
|
* @param null|Project[] $projects
|
||||||
|
* @param null|int[] $tagIds
|
||||||
*
|
*
|
||||||
* @return TimeEntry[]
|
* @return TimeEntry[]
|
||||||
*/
|
*/
|
||||||
public function findForExport(
|
public function findForExport(
|
||||||
DateTimeImmutable $after,
|
DateTimeImmutable $after,
|
||||||
DateTimeImmutable $before,
|
DateTimeImmutable $before,
|
||||||
?User $user = null,
|
?array $users = null,
|
||||||
?Project $project = null,
|
?array $projects = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createQueryBuilder('te')
|
$qb = $this->createQueryBuilder('te')
|
||||||
@@ -49,15 +51,15 @@ class TimeEntryRepository extends ServiceEntityRepository
|
|||||||
->orderBy('te.startedAt', 'ASC')
|
->orderBy('te.startedAt', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
if (null !== $user) {
|
if (null !== $users && [] !== $users) {
|
||||||
$qb->andWhere('te.user = :user')
|
$qb->andWhere('te.user IN (:users)')
|
||||||
->setParameter('user', $user)
|
->setParameter('users', $users)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $project) {
|
if (null !== $projects && [] !== $projects) {
|
||||||
$qb->andWhere('te.project = :project')
|
$qb->andWhere('te.project IN (:projects)')
|
||||||
->setParameter('project', $project)
|
->setParameter('projects', $projects)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user