Compare commits
52 Commits
c0b16ef6dc
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4216f1b5a1 | ||
| c72f17eb93 | |||
| 4c19b68156 | |||
| 63e4af785e | |||
| f5e41bc377 | |||
| f978df6a4b | |||
| 98e832afa5 | |||
| cbfbb16c59 | |||
| 354d994766 | |||
| 06771c17e0 | |||
| 9908f34580 | |||
| 7bf632c1da | |||
| 66a75c6b6a | |||
| f53b2f3d1f | |||
| c9a3c7c5f8 | |||
| 5777e8386f | |||
| 06f2a9e1ea | |||
| b5fa9e7d06 | |||
| 73ecbbc95b | |||
| 5327155a80 | |||
| 9e638c32b8 | |||
| bc331982d5 | |||
| 1e311242a9 | |||
| 97c6ef6a52 | |||
| 245a8a932e | |||
| 28fbc73248 | |||
| df00b27a64 | |||
| ee38f99022 | |||
| 48ef434f8b | |||
| e53862d71f | |||
| 52063cb4fa | |||
| 06832c24e1 | |||
| 8fbafc1f8a | |||
| 585cc3368f | |||
| 043826075d | |||
| 8ec98a593a | |||
| 3dd2d39222 | |||
| cfaa6c42ec | |||
| a36cd92a7f | |||
| bfffbe7041 | |||
| c9993ef32d | |||
| efc3742fff | |||
| e047b98bed | |||
| 758c9f6fbd | |||
| 2c93e83e6b | |||
| 25b648a1b1 | |||
| 445f51b473 | |||
| f888a29e0a | |||
| b48ca10304 | |||
| 802659434f | |||
| 25aef9b2d5 | |||
| 0733ac16cd |
2
.env
2
.env
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
GITEA_ENCRYPTION_KEY=
|
||||
ENCRYPTION_KEY=aaaaaaaaa
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -12,22 +12,23 @@ 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)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor)
|
||||
src/Repository/ # Repositories Doctrine
|
||||
src/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, ClientDrawer, ProjectDrawer, ProjectGroupTab, TaskCard, TaskDrawer, TaskModal, TaskEffortDrawer, TaskGroupDrawer, TaskPriorityDrawer, TaskStatusDrawer, TaskTagDrawer, Admin*Tab, SidebarLink, SidebarTimer, TimeEntryBlock, TimeEntryContextMenu, TimeEntryDrawer, TimeEntryList, TimeTrackingCalendar, UserDrawer, ConfirmDeleteStatusModal, ConfirmDeleteTaskModal)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/)
|
||||
frontend/composables/# Composables (useApi, useAppVersion)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
|
||||
@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
@@ -24,3 +25,17 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventListener\TaskDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\State\TaskDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.0'
|
||||
app.version: '0.1.1'
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
@@ -56,3 +57,4 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads_data:
|
||||
|
||||
@@ -5,6 +5,8 @@ server {
|
||||
root /var/www/html/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[Date]
|
||||
; Defines the default timezone used by the date functions
|
||||
; http://php.net/date.timezone
|
||||
date.timezone = Europe/Paris
|
||||
date.timezone = Europe/Paris
|
||||
|
||||
[Upload]
|
||||
upload_max_filesize = 50M
|
||||
post_max_size = 55M
|
||||
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
File diff suppressed because it is too large
Load Diff
1302
docs/superpowers/plans/2026-03-15-task-documents.md
Normal file
1302
docs/superpowers/plans/2026-03-15-task-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Time Tracking (Toggl-style Timer)
|
||||
|
||||
## Résumé
|
||||
|
||||
Système de suivi de temps type Toggl intégré à Lesstime. Permet de démarrer des timers depuis les tickets (TaskCard) ou à vide depuis la sidebar, visualiser les temps sur un calendrier semaine/jour, et gérer les entrées de temps (drag, resize, copier-coller).
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Entité `TimeEntry`
|
||||
|
||||
| Champ | Type | Contraintes |
|
||||
|-------|------|-------------|
|
||||
| `id` | integer | PK, auto-increment |
|
||||
| `title` | string(255) | nullable |
|
||||
| `description` | text | nullable |
|
||||
| `startedAt` | datetimetz_immutable | requis (stocké en UTC) |
|
||||
| `stoppedAt` | datetimetz_immutable | nullable (null = timer actif, stocké en UTC) |
|
||||
| `user` | ManyToOne → User | requis, CASCADE on delete |
|
||||
| `project` | ManyToOne → Project | nullable, SET NULL on delete |
|
||||
| `task` | ManyToOne → Task | nullable, SET NULL on delete |
|
||||
| `types` | ManyToMany → TaskType | join table `time_entry_task_type` |
|
||||
|
||||
### Règles métier
|
||||
|
||||
- Un seul timer actif (`stoppedAt = null`) par user à la fois
|
||||
- `stoppedAt` > `startedAt` si renseigné
|
||||
- Les entrées de temps peuvent se chevaucher
|
||||
- Démarrage depuis un ticket : copie `title`, `project`, `task`, `types` depuis la Task. Le `user` est toujours le user connecté (pas l'assignee du ticket)
|
||||
- Démarrage à vide : seuls `startedAt` et `user` (connecté) sont renseignés, le reste peut être complété après
|
||||
- Unicité timer actif : index partiel unique sur `(user_id) WHERE stopped_at IS NULL`
|
||||
- Entrées traversant minuit : tronquées visuellement à la fin du jour, la suite s'affiche dans la colonne du jour suivant
|
||||
- Toutes les dates sont stockées et échangées en UTC. Le frontend convertit en heure locale pour l'affichage
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Préfixe `/api`.
|
||||
|
||||
### Sécurité / Autorisations
|
||||
|
||||
- Tout user authentifié peut lire les entrées de tous les users (filtrage par user côté frontend)
|
||||
- Un user peut créer/modifier/supprimer ses propres entrées
|
||||
- Un ROLE_ADMIN peut créer/modifier/supprimer les entrées de n'importe qui
|
||||
- Assigner un temps à un autre user (`user` ≠ soi-même) requiert ROLE_ADMIN
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/time_entries` | Liste avec filtres : `user`, `project`, `startedAt[after]`, `startedAt[before]`, `types` |
|
||||
| `POST` | `/api/time_entries` | Créer une entrée ou démarrer un timer |
|
||||
| `PATCH` | `/api/time_entries/{id}` | Modifier (stopper, compléter, redimensionner, déplacer) |
|
||||
| `DELETE` | `/api/time_entries/{id}` | Supprimer |
|
||||
| `GET` | `/api/time_entries/active` | Timer actif du user connecté (custom Provider, `uriTemplate` avec priorité > item route) |
|
||||
|
||||
## Frontend
|
||||
|
||||
### Store Pinia `useTimerStore`
|
||||
|
||||
```typescript
|
||||
state: {
|
||||
activeEntry: TimeEntry | null
|
||||
}
|
||||
|
||||
getters: {
|
||||
isRunning: boolean // activeEntry !== null
|
||||
elapsed: number // calculé via setInterval: now - activeEntry.startedAt
|
||||
}
|
||||
|
||||
actions: {
|
||||
fetchActive() // GET /api/time_entries/active — appelé au chargement app
|
||||
start() // POST à vide (startedAt: now, user: currentUser)
|
||||
startFromTask(task: Task) // Stoppe le timer actif si existant, puis POST avec données du ticket (user = connecté, pas assignee)
|
||||
stop() // PATCH stoppedAt: now
|
||||
}
|
||||
```
|
||||
|
||||
Le temps est fiable même si le navigateur est fermé : `startedAt` est en base, le compteur affiche toujours `now - startedAt` au rechargement.
|
||||
|
||||
### Timer dans la sidebar (bas à gauche)
|
||||
|
||||
- **Inactif** : affiche `00:00:00` + bouton play (démarrage à vide)
|
||||
- **Actif** : compteur temps réel + bouton stop
|
||||
- Toujours visible, dans le layout `default.vue`
|
||||
|
||||
### Bouton play sur TaskCard
|
||||
|
||||
- Bouton play existant sur les cartes du kanban
|
||||
- Clic → `timerStore.startFromTask(task)`
|
||||
- Si un timer est déjà actif : stop automatique de l'ancien, puis démarrage du nouveau
|
||||
|
||||
### Page "Suivi des temps"
|
||||
|
||||
**Route** : `/time-tracking`
|
||||
**Lien sidebar** : "Suivi de temps" (icône horloge)
|
||||
|
||||
#### Header
|
||||
|
||||
- Titre "Suivi des temps"
|
||||
- Mois/année en orange
|
||||
- Toggle vue : **Semaine** / **Jour** avec flèches `< >`
|
||||
- Filtres : **User** (select, défaut = user connecté), **Type** (select TaskType)
|
||||
- Bouton **"+ Ajouter une Activité"**
|
||||
|
||||
#### Grille calendrier
|
||||
|
||||
- **Axe Y** : 00:00 → 23:59 (minuit à minuit)
|
||||
- **Axe X** : 7 colonnes (semaine, Lun→Dim) ou 1 colonne (jour)
|
||||
- Chaque colonne : jour + date + total heures sous la date
|
||||
|
||||
#### Blocs de temps
|
||||
|
||||
- **Couleur** = couleur du projet
|
||||
- **Contenu** : titre, nom du projet (petit), badge type coloré, durée
|
||||
- Les blocs peuvent se chevaucher
|
||||
|
||||
#### Interactions
|
||||
|
||||
| Action | Comportement |
|
||||
|--------|-------------|
|
||||
| **Clic sur un bloc** | Ouvre le drawer en mode édition |
|
||||
| **Drag & drop d'un bloc** | Déplacer vers un autre créneau ou autre jour |
|
||||
| **Resize (bord bas)** | Redimensionner la durée (modifie `stoppedAt`) |
|
||||
| **Clic sur créneau vide** | Ouvre le drawer en mode création avec heure début pré-remplie |
|
||||
| **Clic droit sur un bloc** | Menu contextuel : Copier, Supprimer |
|
||||
| **Clic droit sur créneau vide** | Menu contextuel : Coller (si un bloc copié) |
|
||||
| **Bouton "+ Ajouter une Activité"** | Ouvre le drawer en mode création |
|
||||
|
||||
### Drawer "Ajouter/Modifier un temps"
|
||||
|
||||
Utilise le composant `AppDrawer` existant.
|
||||
|
||||
**Champs** :
|
||||
- Titre (input text)
|
||||
- Description (textarea)
|
||||
- Heure début (datetime picker)
|
||||
- Heure fin (datetime picker)
|
||||
- User (select, défaut = user connecté, peut assigner à un autre)
|
||||
- Projet (select)
|
||||
- Type (select TaskType)
|
||||
- Bouton Enregistrer
|
||||
|
||||
En mode édition : champs pré-remplis avec les données du TimeEntry.
|
||||
|
||||
## Service frontend
|
||||
|
||||
### `useTimeEntryService()`
|
||||
|
||||
```typescript
|
||||
getByDateRange(params: { after: string, before: string, user?: number, types?: number[] }): Promise<TimeEntry[]>
|
||||
getActive(): Promise<TimeEntry | null>
|
||||
create(payload: TimeEntryWrite): Promise<TimeEntry>
|
||||
update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry>
|
||||
remove(id: number): Promise<void>
|
||||
```
|
||||
|
||||
### DTO `TimeEntry`
|
||||
|
||||
```typescript
|
||||
type TimeEntry = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
startedAt: string // ISO datetime
|
||||
stoppedAt: string | null // null = timer actif
|
||||
user: UserData
|
||||
project: Project | null
|
||||
task: Task | null
|
||||
types: TaskType[]
|
||||
}
|
||||
|
||||
type TimeEntryWrite = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
startedAt: string
|
||||
stoppedAt?: string | null
|
||||
user: string // IRI
|
||||
project?: string | null // IRI
|
||||
task?: string | null // IRI
|
||||
types?: string[] // IRIs
|
||||
}
|
||||
```
|
||||
|
||||
## Modifications sur l'existant
|
||||
|
||||
- **DTO `Task`** : ajouter le champ `project: Project` (nécessaire pour `startFromTask`)
|
||||
- **`TaskCard.vue`** : connecter le bouton play existant à `timerStore.startFromTask(task)`
|
||||
- **`default.vue`** : intégrer `SidebarTimer.vue` en bas de la sidebar (au-dessus du bouton collapse). En mode collapsed : afficher uniquement le bouton play/stop sans le compteur texte
|
||||
- **Sidebar links** : ajouter le lien "Suivi de temps" vers `/time-tracking`
|
||||
|
||||
## Composants frontend
|
||||
|
||||
| Composant | Rôle |
|
||||
|-----------|------|
|
||||
| `TimeTrackingCalendar.vue` | Grille calendrier (semaine/jour) avec blocs |
|
||||
| `TimeEntryBlock.vue` | Bloc de temps individuel (drag, resize) |
|
||||
| `TimeEntryDrawer.vue` | Drawer ajout/modification |
|
||||
| `TimeEntryContextMenu.vue` | Menu contextuel (copier, coller, supprimer) |
|
||||
| `SidebarTimer.vue` | Widget timer dans la sidebar |
|
||||
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal file
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# BookStack Connector — Design Spec
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**BookStack version:** v25.12.8
|
||||
**Pattern:** Mirror of Gitea connector
|
||||
|
||||
## Overview
|
||||
|
||||
Connecteur BookStack permettant de lier des documents (pages et livres) du wiki à des tâches Lesstime. Chaque projet peut être associé à une étagère (shelf) BookStack, et les utilisateurs peuvent rechercher et lier des pages/livres de cette étagère à leurs tâches.
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Types liés : **pages** et **livres** (books)
|
||||
- Niveau projet : liaison à une **étagère** (shelf)
|
||||
- Niveau tâche : liaison à une ou plusieurs **pages/livres** de l'étagère du projet
|
||||
- Recherche : filtrée dans l'étagère du projet uniquement
|
||||
- Stockage : **référence** (titre + URL), pas d'aperçu du contenu
|
||||
- Auth BookStack : Token ID + Token Secret (header `Authorization: Token {id}:{secret}`)
|
||||
|
||||
## Backend
|
||||
|
||||
### Entités
|
||||
|
||||
#### BookStackConfiguration (singleton)
|
||||
|
||||
```php
|
||||
// src/Entity/BookStackConfiguration.php
|
||||
class BookStackConfiguration
|
||||
{
|
||||
private ?int $id;
|
||||
private ?string $url = null;
|
||||
private ?string $encryptedTokenId = null;
|
||||
private ?string $encryptedTokenSecret = null;
|
||||
|
||||
public function hasToken(): bool; // vérifie que les deux sont présents
|
||||
}
|
||||
```
|
||||
|
||||
- Chiffrement via `TokenEncryptor` existant (même pattern que Gitea)
|
||||
- Repository avec `findSingleton()`
|
||||
|
||||
#### TaskBookStackLink
|
||||
|
||||
```php
|
||||
// src/Entity/TaskBookStackLink.php
|
||||
class TaskBookStackLink
|
||||
{
|
||||
private ?int $id;
|
||||
private Task $task; // ManyToOne, CASCADE on delete
|
||||
private int $bookstackId; // ID dans BookStack
|
||||
private string $bookstackType; // 'page' | 'book'
|
||||
private string $title; // titre au moment du lien (cache)
|
||||
private string $url; // URL complète
|
||||
private \DateTimeImmutable $createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
#### Project (extension)
|
||||
|
||||
Ajout de deux champs :
|
||||
- `bookstackShelfId` (nullable int)
|
||||
- `bookstackShelfName` (nullable string) — cache du nom pour affichage
|
||||
|
||||
### Service
|
||||
|
||||
#### BookStackApiService
|
||||
|
||||
```php
|
||||
// src/Service/BookStackApiService.php
|
||||
class BookStackApiService
|
||||
{
|
||||
public function testConnection(): bool;
|
||||
public function listShelves(): array;
|
||||
public function searchInShelf(int $shelfId, string $query): array;
|
||||
public function getPage(int $id): array;
|
||||
public function getBook(int $id): array;
|
||||
}
|
||||
```
|
||||
|
||||
- Utilise `HttpClientInterface` (Symfony HttpClient)
|
||||
- Auth : header `Authorization: Token {tokenId}:{tokenSecret}`
|
||||
- Timeout : 10 secondes
|
||||
- `testConnection()` : GET `/api/docs.json`
|
||||
- `listShelves()` : GET `/api/shelves` (paginé via `count`/`offset`, pas `page`/`limit` — spécificité BookStack)
|
||||
- `searchInShelf()` : algorithme en 3 étapes :
|
||||
1. GET `/api/shelves/{shelfId}` → récupère la liste des `books` de l'étagère (IDs)
|
||||
2. GET `/api/search?query={query} {type:page|book}` → recherche globale (espace entre query et filtre type, BookStack syntax)
|
||||
3. Filtre côté PHP : pour les **books**, vérifie que `book.id` est dans la liste de l'étagère ; pour les **pages**, vérifie que `page.book_id` est dans la liste. Exclut les résultats `chapter` et `bookshelf`.
|
||||
- Note : la liste des books de l'étagère peut être cachée en mémoire pour la durée de la requête.
|
||||
- `getPage()` : GET `/api/pages/{id}`
|
||||
- `getBook()` : GET `/api/books/{id}`
|
||||
|
||||
#### BookStackApiException
|
||||
|
||||
```php
|
||||
// src/Exception/BookStackApiException.php
|
||||
class BookStackApiException extends \RuntimeException {}
|
||||
```
|
||||
|
||||
### API Resources & Endpoints
|
||||
|
||||
#### Admin
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
|
||||
| PUT | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
|
||||
| POST | `/api/settings/bookstack/test` | BookStackTestConnection | ROLE_ADMIN |
|
||||
|
||||
**BookStackSettings** (DTO) :
|
||||
- Read : `url`, `hasToken`
|
||||
- Write : `url`, `tokenId`, `tokenSecret`
|
||||
|
||||
**BookStackTestConnection** (DTO) :
|
||||
- Read : `success`
|
||||
|
||||
#### Projet
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/bookstack/shelves` | BookStackShelf | ROLE_ADMIN |
|
||||
|
||||
**BookStackShelf** (DTO) :
|
||||
- Read : `id`, `name`
|
||||
|
||||
L'étagère sélectionnée est sauvée via le PATCH existant de Project (`bookstackShelfId`, `bookstackShelfName`).
|
||||
|
||||
#### Tâche
|
||||
|
||||
| Méthode | Route | Ressource API Platform | Sécurité |
|
||||
|---------|-------|----------------------|----------|
|
||||
| GET | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
|
||||
| POST | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
|
||||
| DELETE | `/api/tasks/{taskId}/bookstack/links/{id}` | BookStackLink | Authenticated |
|
||||
| GET | `/api/tasks/{taskId}/bookstack/search?q=` | BookStackSearchResult | Authenticated |
|
||||
|
||||
**BookStackLink** (DTO) :
|
||||
- Read : `id`, `bookstackId`, `bookstackType`, `title`, `url`, `createdAt`
|
||||
- Write : `bookstackId`, `bookstackType`, `title`, `url`
|
||||
|
||||
**BookStackSearchResult** (DTO) :
|
||||
- Read : `id`, `type`, `name`, `url`
|
||||
|
||||
### State Providers / Processors
|
||||
|
||||
| Classe | Rôle |
|
||||
|--------|------|
|
||||
| `BookStackSettingsProvider` | Lit config singleton, retourne DTO masqué |
|
||||
| `BookStackSettingsProcessor` | Persiste config, chiffre tokens |
|
||||
| `BookStackTestConnectionProvider` | Appelle `testConnection()` |
|
||||
| `BookStackShelfProvider` | Appelle `listShelves()`, mappe en DTOs |
|
||||
| `BookStackLinkProvider` | Lit `TaskBookStackLink` par task ID |
|
||||
| `BookStackLinkProcessor` | POST : crée lien en DB / DELETE : supprime |
|
||||
| `BookStackSearchResultProvider` | Appelle `searchInShelf()`, mappe en DTOs |
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
CREATE TABLE bookstack_configuration (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
url VARCHAR(255) DEFAULT NULL,
|
||||
encrypted_token_id TEXT DEFAULT NULL,
|
||||
encrypted_token_secret TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE task_bookstack_link (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
task_id INT NOT NULL REFERENCES task(id) ON DELETE CASCADE,
|
||||
bookstack_id INT NOT NULL,
|
||||
bookstack_type VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IDX_task_bookstack_link_task_id ON task_bookstack_link (task_id);
|
||||
CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_bookstack_link (task_id, bookstack_id, bookstack_type);
|
||||
|
||||
ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL;
|
||||
ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL;
|
||||
```
|
||||
|
||||
### Variable d'environnement
|
||||
|
||||
Prérequis : renommer `GITEA_ENCRYPTION_KEY` en `ENCRYPTION_KEY` (générique) dans `TokenEncryptor`, `.env`, et `docker/.env.docker`. Mettre à jour le message d'erreur dans `TokenEncryptor`. Cela permet de réutiliser le même service pour chiffrer les tokens BookStack (deux appels `encrypt()`/`decrypt()` : un pour tokenId, un pour tokenSecret).
|
||||
|
||||
### Notes techniques
|
||||
|
||||
- `BookStackTestConnectionProvider` implémente à la fois `ProviderInterface` et `ProcessorInterface` (même pattern que `GiteaTestConnectionProvider`)
|
||||
- Les endpoints collection du frontend utilisent `extractHydraMembers()` pour extraire les résultats des réponses Hydra
|
||||
- Les titres/URLs stockés dans `TaskBookStackLink` sont des snapshots au moment du lien — pas de rafraîchissement automatique (intentionnel)
|
||||
- Le select étagère dans `ProjectDrawer` n'est affiché que pour les admins (endpoint `ROLE_ADMIN`)
|
||||
|
||||
## Frontend
|
||||
|
||||
### Service
|
||||
|
||||
```typescript
|
||||
// frontend/services/bookstack.ts
|
||||
export function useBookStackService() {
|
||||
// Admin
|
||||
async function getSettings(): Promise<BookStackSettings>
|
||||
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings>
|
||||
async function testConnection(): Promise<BookStackTestResult>
|
||||
|
||||
// Projet
|
||||
async function listShelves(): Promise<BookStackShelf[]>
|
||||
|
||||
// Tâche
|
||||
async function getLinks(taskId: number): Promise<BookStackLink[]>
|
||||
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink>
|
||||
async function removeLink(taskId: number, linkId: number): Promise<void>
|
||||
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]>
|
||||
}
|
||||
```
|
||||
|
||||
### DTOs
|
||||
|
||||
```typescript
|
||||
// frontend/services/dto/bookstack.ts
|
||||
type BookStackSettings = { url: string | null; hasToken: boolean }
|
||||
type BookStackSettingsWrite = { url: string | null; tokenId: string | null; tokenSecret: string | null }
|
||||
type BookStackTestResult = { success: boolean }
|
||||
type BookStackShelf = { id: number; name: string }
|
||||
type BookStackLink = { id: number; bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string; createdAt: string }
|
||||
type BookStackLinkCreate = { bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string }
|
||||
type BookStackSearchResult = { id: number; type: 'page' | 'book'; name: string; url: string }
|
||||
```
|
||||
|
||||
### Composants
|
||||
|
||||
#### AdminBookStackTab.vue
|
||||
|
||||
Onglet admin (même pattern que `AdminGiteaTab.vue`) :
|
||||
- Champs : URL, Token ID, Token Secret
|
||||
- Bouton "Tester la connexion" avec indicateur résultat
|
||||
- Indicateur "Token configuré" (ne montre jamais le token)
|
||||
- Sauvegarde via `saveSettings()`
|
||||
|
||||
#### ProjectDrawer.vue (extension)
|
||||
|
||||
- Si BookStack est configuré : select pour choisir une étagère
|
||||
- Charge `listShelves()` à l'ouverture
|
||||
- Sauvegarde `bookstackShelfId` + `bookstackShelfName` sur le projet via PATCH
|
||||
|
||||
#### TaskBookStackLinks.vue
|
||||
|
||||
Petit composant intégré dans `TaskModal.vue`, visible directement :
|
||||
- **Input de recherche** avec debounce (~300ms) → appel `search(taskId, query)` → dropdown résultats
|
||||
- Chaque résultat : icône (page 📄 / livre 📕) + titre — clic pour ajouter
|
||||
- **Liste des liens** sous le champ recherche : icône type + titre cliquable (ouvre BookStack dans nouvel onglet) + bouton × supprimer
|
||||
- Affiché uniquement si le projet de la tâche a une shelf BookStack configurée
|
||||
- Charge les liens existants au mount via `getLinks(taskId)`
|
||||
|
||||
#### TaskModal.vue (extension)
|
||||
|
||||
- Ajoute `<TaskBookStackLinks>` dans le modal, conditionné par `project.bookstackShelfId`
|
||||
- Passe `taskId` et `projectId` en props
|
||||
|
||||
## Fichiers à créer/modifier
|
||||
|
||||
### Backend — Nouveaux fichiers
|
||||
|
||||
```
|
||||
src/Entity/BookStackConfiguration.php
|
||||
src/Entity/TaskBookStackLink.php
|
||||
src/Repository/BookStackConfigurationRepository.php
|
||||
src/Repository/TaskBookStackLinkRepository.php
|
||||
src/Service/BookStackApiService.php
|
||||
src/Exception/BookStackApiException.php
|
||||
src/ApiResource/BookStackSettings.php
|
||||
src/ApiResource/BookStackTestConnection.php
|
||||
src/ApiResource/BookStackShelf.php
|
||||
src/ApiResource/BookStackLink.php
|
||||
src/ApiResource/BookStackSearchResult.php
|
||||
src/State/BookStackSettingsProvider.php
|
||||
src/State/BookStackSettingsProcessor.php
|
||||
src/State/BookStackTestConnectionProvider.php
|
||||
src/State/BookStackShelfProvider.php
|
||||
src/State/BookStackLinkProvider.php
|
||||
src/State/BookStackLinkProcessor.php
|
||||
src/State/BookStackSearchResultProvider.php
|
||||
migrations/VersionXXXX.php
|
||||
```
|
||||
|
||||
### Backend — Fichiers modifiés
|
||||
|
||||
```
|
||||
src/Entity/Project.php (ajout bookstackShelfId, bookstackShelfName)
|
||||
src/Service/TokenEncryptor.php (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
|
||||
```
|
||||
|
||||
### Config — Fichiers modifiés
|
||||
|
||||
```
|
||||
.env (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
|
||||
```
|
||||
|
||||
> Note : `docker/.env.docker` ne contient pas `GITEA_ENCRYPTION_KEY`. Les développeurs utilisant `docker/.env.docker.local` doivent le mettre à jour manuellement.
|
||||
|
||||
### Frontend — Nouveaux fichiers
|
||||
|
||||
```
|
||||
frontend/services/bookstack.ts
|
||||
frontend/services/dto/bookstack.ts
|
||||
frontend/components/admin/AdminBookStackTab.vue
|
||||
frontend/components/task/TaskBookStackLinks.vue
|
||||
```
|
||||
|
||||
### Frontend — Fichiers modifiés
|
||||
|
||||
```
|
||||
frontend/components/task/TaskModal.vue (ajout TaskBookStackLinks)
|
||||
frontend/components/project/ProjectDrawer.vue (ajout select étagère)
|
||||
frontend/components/admin/ (ajout onglet BookStack dans la page admin)
|
||||
```
|
||||
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal file
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Portail Client — Design Spec
|
||||
|
||||
## Résumé
|
||||
|
||||
Ajout d'un portail client dans Lesstime permettant aux utilisateurs-clients de soumettre des tickets (bug, amélioration, autre) sur leurs projets, suivre l'évolution de leur traitement, et joindre des documents. Les utilisateurs internes (ROLE_ADMIN, ROLE_USER) gèrent les tickets côté admin et peuvent les lier manuellement à des tasks existantes. Un système de notifications in-app informe les parties prenantes des événements clés.
|
||||
|
||||
## Décisions d'architecture
|
||||
|
||||
- **ClientTicket est une entité séparée de Task** — cycle de vie indépendant, meilleure séparation de sécurité, maintenance simplifiée
|
||||
- **Même application, vue adaptée par rôle** — pas de portail séparé. ROLE_CLIENT voit les pages `/portal`, ROLE_ADMIN/ROLE_USER voit l'app interne
|
||||
- **Pas de commentaires/échanges** — communication unidirectionnelle : le client soumet, voit les changements de statut, c'est tout
|
||||
- **Notifications in-app uniquement** — pas d'email pour le moment
|
||||
- **Lien ticket-task manuel** — le manager crée des tasks et les lie explicitement à un ticket client
|
||||
- **TaskDocument conservée** — l'entité `TaskDocument` n'est pas renommée, elle est généralisée avec un champ `clientTicket` nullable
|
||||
- **Français uniquement** — l'interface est en français pour le moment, l'anglais pourra être ajouté plus tard
|
||||
|
||||
## Prérequis : sécurisation des endpoints existants
|
||||
|
||||
Avant l'introduction du rôle `ROLE_CLIENT`, il faut sécuriser l'application existante.
|
||||
|
||||
### Modification de `User::getRoles()`
|
||||
|
||||
Actuellement, `User::getRoles()` ajoute inconditionnellement `ROLE_USER` à tous les utilisateurs. Un utilisateur `ROLE_CLIENT` hériterait donc de `ROLE_USER` et pourrait accéder à toutes les données internes.
|
||||
|
||||
**Correction** : `getRoles()` doit ajouter `ROLE_USER` uniquement si l'utilisateur n'a PAS le rôle `ROLE_CLIENT` :
|
||||
|
||||
```php
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
if (!in_array('ROLE_CLIENT', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
|
||||
return array_unique($roles);
|
||||
}
|
||||
```
|
||||
|
||||
### Ajout de `security` sur les endpoints existants
|
||||
|
||||
Les endpoints existants suivants n'ont pas d'annotation `security` explicite et doivent recevoir `security: "is_granted('ROLE_USER')"` sur leurs opérations `GetCollection` et `Get` :
|
||||
|
||||
| Entité | Opérations à sécuriser |
|
||||
|--------|----------------------|
|
||||
| `Task` | GetCollection, Get |
|
||||
| `Project` | GetCollection, Get |
|
||||
| `Client` | GetCollection, Get |
|
||||
| `TaskStatus` | GetCollection, Get |
|
||||
| `TaskEffort` | GetCollection, Get |
|
||||
| `TaskPriority` | GetCollection, Get |
|
||||
| `TaskTag` | GetCollection, Get |
|
||||
| `TaskGroup` | GetCollection, Get |
|
||||
| `TimeEntry` | GetCollection, Get |
|
||||
|
||||
Cela garantit qu'un utilisateur `ROLE_CLIENT` ne peut pas accéder aux ressources internes via l'API.
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Entité `ClientTicket`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `number` | int | Auto-généré, unique par projet (voir stratégie ci-dessous) |
|
||||
| `type` | string (enum) | `bug`, `improvement`, `other` |
|
||||
| `title` | string | Requis |
|
||||
| `description` | text | Requis |
|
||||
| `url` | string (nullable) | Affiché uniquement si `type = bug` |
|
||||
| `status` | string (enum) | `new`, `in_progress`, `done`, `rejected` |
|
||||
| `statusComment` | text (nullable) | Commentaire du manager lors d'un changement de statut |
|
||||
| `project` | ManyToOne → Project | Requis |
|
||||
| `submittedBy` | ManyToOne → User (nullable) | L'utilisateur-client ayant soumis le ticket. **ON DELETE SET NULL** — ne pas détruire l'historique lors de la suppression d'un utilisateur |
|
||||
| `createdAt` | DateTimeImmutable | Auto |
|
||||
| `updatedAt` | DateTimeImmutable | Auto |
|
||||
|
||||
#### Stratégie de numérotation
|
||||
|
||||
Numéro incrémental par projet : `SELECT MAX(number) + 1 FROM client_ticket WHERE project_id = :project`. Contrainte unique sur `(project_id, number)` avec retry en cas de conflit (concurrent insert). Le numéro affiché sera formaté `CT-001`, `CT-002`, etc. en frontend.
|
||||
|
||||
### Statuts des tickets (enum fixe, non configurable)
|
||||
|
||||
| Statut | Description |
|
||||
|--------|-------------|
|
||||
| `new` | Ticket venant d'être soumis |
|
||||
| `in_progress` | Pris en charge par un manager |
|
||||
| `done` | Résolu, client notifié |
|
||||
| `rejected` | Non retenu — `statusComment` obligatoire |
|
||||
|
||||
#### Transitions de statut autorisées
|
||||
|
||||
Toutes les transitions sont autorisées, **sauf** :
|
||||
- `done` → `new` (interdit)
|
||||
- `rejected` → `new` (interdit)
|
||||
|
||||
Un ticket `done` peut repasser en `in_progress` si besoin. Un ticket `rejected` peut passer en `in_progress`. Le Processor valide les transitions et rejette les transitions interdites.
|
||||
|
||||
### Entité `Notification`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `user` | ManyToOne → User | Destinataire |
|
||||
| `type` | string | `ticket_created`, `ticket_status_changed` |
|
||||
| `title` | string | Titre court |
|
||||
| `message` | text | Contenu |
|
||||
| `relatedTicket` | ManyToOne → ClientTicket (nullable) | Lien vers le ticket concerné |
|
||||
| `isRead` | bool | `false` par défaut |
|
||||
| `createdAt` | DateTimeImmutable | Auto |
|
||||
|
||||
### Modifications sur `User`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `client` | ManyToOne → Client (nullable) | `null` = utilisateur interne, set = utilisateur-client |
|
||||
| `allowedProjects` | ManyToMany → Project | Projets auxquels l'utilisateur-client a accès |
|
||||
|
||||
Nouveau rôle : `ROLE_CLIENT`
|
||||
|
||||
#### Groupes de sérialisation
|
||||
|
||||
| Champ | Groupes |
|
||||
|-------|---------|
|
||||
| `client` | `me:read`, `user:read`, `user:write` |
|
||||
| `allowedProjects` | `me:read`, `user:read`, `user:write` |
|
||||
|
||||
Règles :
|
||||
- Plusieurs utilisateurs par client (1+)
|
||||
- Les utilisateurs-clients sont assignés à des projets spécifiques (pas tous les projets du client)
|
||||
- L'admin crée les comptes utilisateurs-clients (pas d'auto-inscription)
|
||||
|
||||
### Modifications sur `Task`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `clientTicket` | ManyToOne → ClientTicket (nullable) | Lien vers un ticket client |
|
||||
|
||||
Le champ `clientTicket` est exposé dans le groupe `task:read` avec les informations de base du ticket (number, type, status, title). Cela permet aux utilisateurs ROLE_USER d'afficher l'icône et le tooltip dans le kanban sans avoir accès à la collection `/api/client_tickets`.
|
||||
|
||||
### Généralisation de `TaskDocument`
|
||||
|
||||
L'entité `TaskDocument` existante est **conservée** (pas de renommage) et généralisée avec un champ supplémentaire :
|
||||
|
||||
| Champ | Modification |
|
||||
|-------|-------------|
|
||||
| `task` | Devient nullable |
|
||||
| `clientTicket` | ManyToOne → ClientTicket (nullable) — ajouté |
|
||||
|
||||
**Contrainte** : au moins un des deux champs `task` / `clientTicket` doit être renseigné (CHECK constraint en base).
|
||||
|
||||
**Processor** : généralisé pour accepter `task` OU `clientTicket` dans le FormData.
|
||||
|
||||
**Sécurité** :
|
||||
- ROLE_ADMIN : accès complet à tous les documents
|
||||
- ROLE_USER : accès aux documents liés à une task (`task IS NOT NULL`)
|
||||
- ROLE_CLIENT : accès aux documents liés à un ticket dont l'utilisateur est le `submittedBy`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Préfixe `/api`.
|
||||
|
||||
### ClientTicket
|
||||
|
||||
| Méthode | Route | Accès | Notes |
|
||||
|---------|-------|-------|-------|
|
||||
| `GET` | `/api/client_tickets` | ROLE_CLIENT : ses propres tickets ; ROLE_ADMIN : tous | Filtres : `project`, `status`, `submittedBy` |
|
||||
| `GET` | `/api/client_tickets/{id}` | Owner ou ROLE_ADMIN | |
|
||||
| `POST` | `/api/client_tickets` | ROLE_CLIENT | `submittedBy` auto-set depuis le token JWT. Le Processor valide que `user.client` n'est pas null (empêche un admin de créer un ticket même via la hiérarchie de rôles) |
|
||||
| `PATCH` | `/api/client_tickets/{id}` | ROLE_ADMIN uniquement | Changement de statut + `statusComment` |
|
||||
| `DELETE` | `/api/client_tickets/{id}` | ROLE_ADMIN | Cascade sur les documents liés |
|
||||
|
||||
**Note** : ROLE_USER n'a PAS accès à la collection `/api/client_tickets`. L'accès en lecture aux informations d'un ticket se fait via le champ `task.clientTicket` exposé dans le groupe `task:read`.
|
||||
|
||||
### Notification
|
||||
|
||||
| Méthode | Route | Accès | Notes |
|
||||
|---------|-------|-------|-------|
|
||||
| `GET` | `/api/notifications` | Authentifié | Auto-filtré par l'utilisateur courant. Paginé : 30 par page |
|
||||
| `PATCH` | `/api/notifications/{id}` | Owner | Marquer comme lu |
|
||||
| `POST` | `/api/notifications/mark-all-read` | Authentifié | **Endpoint Symfony custom** (controller dédié, pas une opération API Platform) |
|
||||
| `GET` | `/api/notifications/unread-count` | Authentifié | Retourne le count |
|
||||
|
||||
**Nettoyage** : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours). Pas implémenté dans la première version.
|
||||
|
||||
### TaskDocument
|
||||
|
||||
- Les endpoints existants restent, avec ajout du filtre `clientTicket`
|
||||
- Le Processor accepte `task` OU `clientTicket`
|
||||
- Sécurité : ROLE_ADMIN (tous), ROLE_USER (documents liés à une task), ROLE_CLIENT (documents liés à un ticket dont l'utilisateur est le `submittedBy`)
|
||||
|
||||
## State Providers & Processors
|
||||
|
||||
### `ClientTicketProvider`
|
||||
|
||||
- ROLE_CLIENT : filtre par `submittedBy` = utilisateur courant
|
||||
- ROLE_ADMIN : retourne tous les tickets
|
||||
- Vérifie que l'utilisateur-client a accès au projet du ticket (via `allowedProjects`)
|
||||
|
||||
### `ClientTicketNumberProcessor`
|
||||
|
||||
- Sur `POST` : auto-génère le numéro via `SELECT MAX(number) FROM client_ticket WHERE project_id = :project` + 1, avec contrainte unique `(project_id, number)` et retry en cas de conflit
|
||||
- Valide que `user.client` n'est pas null (empêche la création par un admin même si ROLE_ADMIN hérite de ROLE_CLIENT)
|
||||
- Set `submittedBy` depuis le token JWT courant
|
||||
- Set `status` à `new`
|
||||
- Set `createdAt` et `updatedAt`
|
||||
|
||||
### `ClientTicketStatusProcessor`
|
||||
|
||||
- Sur `PATCH` : valide la transition de statut
|
||||
- Transitions interdites : `done` → `new`, `rejected` → `new`
|
||||
- `statusComment` obligatoire si le nouveau statut est `rejected`
|
||||
- Met à jour `updatedAt`
|
||||
|
||||
### `ClientTicketNotificationProcessor`
|
||||
|
||||
- Sur `POST` (ticket créé) : crée une `Notification` pour tous les utilisateurs ROLE_ADMIN
|
||||
- Type : `ticket_created`
|
||||
- Title : "Nouveau ticket client CT-XXX"
|
||||
- Message : titre du ticket + nom du projet
|
||||
- Sur `PATCH` (changement de statut) : crée une `Notification` pour le `submittedBy`
|
||||
- Type : `ticket_status_changed`
|
||||
- Title : "Ticket CT-XXX mis à jour"
|
||||
- Message : nouveau statut + `statusComment` si présent
|
||||
|
||||
### `NotificationProvider`
|
||||
|
||||
- Toujours filtré par l'utilisateur courant (`user` = token JWT)
|
||||
- Paginé : 30 résultats par page
|
||||
- Endpoint `unread-count` : `SELECT COUNT(*) WHERE user = :user AND isRead = false`
|
||||
|
||||
### `MarkAllReadController`
|
||||
|
||||
Endpoint custom Symfony (`POST /api/notifications/mark-all-read`) :
|
||||
- Récupère l'utilisateur depuis le token JWT
|
||||
- Exécute `UPDATE notification SET is_read = true WHERE user_id = :user AND is_read = false`
|
||||
- Retourne `204 No Content`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Routing & Middleware
|
||||
|
||||
Modification de `auth.global.ts` :
|
||||
- ROLE_CLIENT → redirigé vers `/portal`, accès bloqué à `/projects`, `/admin`, `/time-tracking`, etc.
|
||||
- ROLE_ADMIN / ROLE_USER → peut accéder à `/portal` pour voir la vue côté client
|
||||
|
||||
### Pages du portail
|
||||
|
||||
#### `/portal` — Liste des projets
|
||||
|
||||
- Affiche les projets auxquels l'utilisateur-client a accès (`allowedProjects`)
|
||||
- Cartes simples : nom du projet, nombre de tickets ouverts
|
||||
- Clic → `/portal/projects/{id}`
|
||||
|
||||
#### `/portal/projects/{id}` — Tickets d'un projet
|
||||
|
||||
- Liste des tickets soumis sur ce projet
|
||||
- Pour chaque ticket : numéro (CT-XXX), type badge, titre, statut badge, date de création
|
||||
- Bouton "Nouveau ticket" → `/portal/projects/{id}/new-ticket`
|
||||
- Clic sur un ticket → modale de détail (lecture seule : titre, description, url, statut, statusComment, documents)
|
||||
|
||||
#### `/portal/projects/{id}/new-ticket` — Formulaire de création
|
||||
|
||||
- Select type : `bug`, `improvement`, `other`
|
||||
- Champ title (requis)
|
||||
- Champ description (requis, textarea)
|
||||
- Champ url (affiché uniquement si `type = bug`)
|
||||
- Zone d'upload de documents (réutilise les composants TaskDocument existants)
|
||||
- Bouton soumettre
|
||||
|
||||
### Modifications des pages existantes
|
||||
|
||||
#### Kanban (`/projects/{id}`)
|
||||
|
||||
- Icône `heroicons:user-circle` affichée à côté du titre de la task si `task.clientTicket` est set
|
||||
- Tooltip au survol : "Lié au ticket client CT-XXX" (données disponibles via `task:read`)
|
||||
|
||||
#### `/my-tasks`
|
||||
|
||||
- Même icône et tooltip que le kanban
|
||||
|
||||
#### `/admin` — Nouvel onglet "Tickets client"
|
||||
|
||||
- Liste de tous les tickets, avec filtres par projet et statut
|
||||
- Pour chaque ticket : numéro, type, titre, statut, projet, soumis par, date
|
||||
- Actions :
|
||||
- Changer le statut (select + champ statusComment si rejection)
|
||||
- Voir le détail du ticket (modale avec documents)
|
||||
|
||||
### Services API
|
||||
|
||||
#### `frontend/services/client-tickets.ts`
|
||||
|
||||
```typescript
|
||||
getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]>
|
||||
getById(id: number): Promise<ClientTicket>
|
||||
create(data: { type: string; title: string; description: string; url?: string; project: string }): Promise<ClientTicket>
|
||||
updateStatus(id: number, data: { status: string; statusComment?: string }): Promise<ClientTicket>
|
||||
remove(id: number): Promise<void>
|
||||
```
|
||||
|
||||
#### `frontend/services/notifications.ts`
|
||||
|
||||
```typescript
|
||||
getAll(page?: number): Promise<Notification[]>
|
||||
markAsRead(id: number): Promise<void>
|
||||
markAllAsRead(): Promise<void>
|
||||
getUnreadCount(): Promise<number>
|
||||
```
|
||||
|
||||
### DTOs TypeScript
|
||||
|
||||
#### `frontend/services/dto/client-ticket.ts`
|
||||
|
||||
```typescript
|
||||
type ClientTicketType = 'bug' | 'improvement' | 'other'
|
||||
type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
|
||||
|
||||
type ClientTicket = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
number: number
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url: string | null
|
||||
status: ClientTicketStatus
|
||||
statusComment: string | null
|
||||
project: string // IRI
|
||||
submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: TaskDocument[]
|
||||
}
|
||||
```
|
||||
|
||||
#### `frontend/services/dto/notification.ts`
|
||||
|
||||
```typescript
|
||||
type NotificationType = 'ticket_created' | 'ticket_status_changed'
|
||||
|
||||
type Notification = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: string // IRI
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
relatedTicket: string | null // IRI
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### Composants réutilisés
|
||||
|
||||
- `TaskDocumentUpload` → généralisé avec prop `clientTicketId` comme alternative à `taskId`
|
||||
- `TaskDocumentList` + `TaskDocumentPreview` → réutilisés dans la modale de détail du ticket
|
||||
|
||||
### Composants à créer
|
||||
|
||||
#### `frontend/components/notification/NotificationBell.vue`
|
||||
|
||||
- Placé dans le header de la navbar
|
||||
- Icône cloche avec badge rouge (nombre de notifications non lues)
|
||||
- Clic → dropdown avec les notifications récentes (paginé, 30 par page)
|
||||
- Chaque notification : titre, message (tronqué), date relative, indicateur lu/non-lu
|
||||
- Clic sur une notification → marque comme lue + navigation vers le ticket lié
|
||||
- Bouton "Tout marquer comme lu"
|
||||
|
||||
### Composable `useNotifications()`
|
||||
|
||||
```typescript
|
||||
const useNotifications = () => {
|
||||
const unreadCount: Ref<number>
|
||||
const notifications: Ref<Notification[]>
|
||||
|
||||
const fetchNotifications: (page?: number) => Promise<void>
|
||||
const fetchUnreadCount: () => Promise<void>
|
||||
const markAsRead: (id: number) => Promise<void>
|
||||
const markAllAsRead: () => Promise<void>
|
||||
|
||||
// Polling toutes les 2 minutes
|
||||
const startPolling: () => void
|
||||
const stopPolling: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Le polling démarre au montage de `NotificationBell` et s'arrête au démontage.
|
||||
|
||||
### Clés i18n
|
||||
|
||||
Ajouter dans `frontend/i18n/locales/fr.json` (français uniquement pour le moment) :
|
||||
|
||||
```
|
||||
# Portal
|
||||
portal.title → "Portail client"
|
||||
portal.projects → "Mes projets"
|
||||
portal.openTickets → "tickets ouverts"
|
||||
portal.newTicket → "Nouveau ticket"
|
||||
portal.ticketDetail → "Détail du ticket"
|
||||
|
||||
# Client Ticket
|
||||
clientTicket.type.bug → "Bug"
|
||||
clientTicket.type.improvement → "Amélioration"
|
||||
clientTicket.type.other → "Autre"
|
||||
clientTicket.status.new → "Nouveau"
|
||||
clientTicket.status.in_progress → "En cours"
|
||||
clientTicket.status.done → "Terminé"
|
||||
clientTicket.status.rejected → "Rejeté"
|
||||
clientTicket.title → "Titre"
|
||||
clientTicket.description → "Description"
|
||||
clientTicket.url → "URL (page concernée)"
|
||||
clientTicket.statusComment → "Commentaire de statut"
|
||||
clientTicket.created → "Ticket créé"
|
||||
clientTicket.statusChanged → "Statut mis à jour"
|
||||
clientTicket.confirmDelete → "Supprimer ce ticket ?"
|
||||
clientTicket.linkedTooltip → "Lié au ticket client {number}"
|
||||
clientTicket.rejectionRequired → "Un commentaire est requis pour rejeter un ticket"
|
||||
|
||||
# Notifications
|
||||
notification.title → "Notifications"
|
||||
notification.markAllRead → "Tout marquer comme lu"
|
||||
notification.empty → "Aucune notification"
|
||||
notification.ticketCreated → "Nouveau ticket client {number}"
|
||||
notification.ticketStatusChanged → "Ticket {number} mis à jour"
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Nouvelles tables
|
||||
|
||||
**`client_ticket`** :
|
||||
- Colonnes correspondant à l'entité `ClientTicket`
|
||||
- Index sur `project_id`
|
||||
- Index sur `submitted_by_id`
|
||||
- Index composite sur `(status, project_id)` pour les filtres admin
|
||||
- Contrainte unique sur `(project_id, number)` pour la numérotation par projet
|
||||
- FK `project_id` → `project.id` ON DELETE CASCADE
|
||||
- FK `submitted_by_id` → `user.id` **ON DELETE SET NULL**
|
||||
|
||||
**`notification`** :
|
||||
- Colonnes correspondant à l'entité `Notification`
|
||||
- Index sur `user_id`
|
||||
- Index composite sur `(user_id, is_read)` pour le count non-lu
|
||||
- FK `user_id` → `user.id` ON DELETE CASCADE
|
||||
- FK `related_ticket_id` → `client_ticket.id` ON DELETE SET NULL
|
||||
|
||||
**`user_allowed_projects`** (table de jointure ManyToMany) :
|
||||
- `user_id` → `user.id` ON DELETE CASCADE
|
||||
- `project_id` → `project.id` ON DELETE CASCADE
|
||||
|
||||
### Modifications de tables existantes
|
||||
|
||||
**`user`** :
|
||||
- Ajout colonne `client_id` (nullable) — FK → `client.id` ON DELETE SET NULL
|
||||
|
||||
**`task`** :
|
||||
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE SET NULL
|
||||
|
||||
**`task_document`** (table conservée, pas de renommage) :
|
||||
- Colonne `task_id` devient nullable
|
||||
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE CASCADE
|
||||
- Contrainte CHECK : `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Hiérarchie des rôles
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
security:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||
```
|
||||
|
||||
### Contrôle d'accès
|
||||
|
||||
| Ressource | ROLE_CLIENT | ROLE_USER | ROLE_ADMIN |
|
||||
|-----------|-------------|-----------|------------|
|
||||
| ClientTicket (ses propres) | Lecture + Création | Lecture via `task:read` (champ `task.clientTicket`) | CRUD complet |
|
||||
| ClientTicket collection `/api/client_tickets` | Ses propres tickets | — | Tous |
|
||||
| Notification (ses propres) | Lecture + Mark as read | Lecture + Mark as read | Lecture + Mark as read |
|
||||
| TaskDocument (lié à une task) | — | Lecture | CRUD complet |
|
||||
| TaskDocument (lié à un ticket) | Lecture + Upload (si `submittedBy` = soi) | — | CRUD complet |
|
||||
| Task, Project, Client, TimeEntry, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup | — | Accès normal (`is_granted('ROLE_USER')`) | Accès normal |
|
||||
| Pages /portal | Accès | Accès | Accès |
|
||||
| Pages /projects, /admin | — | Accès | Accès |
|
||||
|
||||
### Validation du Provider ClientTicket
|
||||
|
||||
- ROLE_CLIENT : vérifie que le projet du ticket fait partie de `allowedProjects` de l'utilisateur
|
||||
- ROLE_CLIENT : ne peut voir que les tickets où `submittedBy` = lui-même
|
||||
- ROLE_ADMIN : aucune restriction
|
||||
|
||||
### Validation du Processor ClientTicket (POST)
|
||||
|
||||
- Vérifie que `user.client` n'est pas null — un utilisateur admin ne peut pas créer de ticket même s'il hérite de ROLE_CLIENT via la hiérarchie de rôles
|
||||
|
||||
## Phases de livraison
|
||||
|
||||
### Phase 1 — Fondations
|
||||
|
||||
1. **Prérequis sécurité** : modifier `User::getRoles()` pour ne plus ajouter `ROLE_USER` aux utilisateurs `ROLE_CLIENT` ; ajouter `security: "is_granted('ROLE_USER')"` sur les opérations GetCollection/Get de Task, Project, Client, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry
|
||||
2. Modifier `User` : ajouter `client` (ManyToOne → Client, nullable), `allowedProjects` (ManyToMany → Project), rôle `ROLE_CLIENT`, groupes de sérialisation `me:read`, `user:read`, `user:write`
|
||||
3. Généraliser `TaskDocument` : `task` devient nullable, ajout `clientTicket` (ManyToOne → ClientTicket, nullable), contrainte CHECK, Processor généralisé
|
||||
4. Créer l'entité `ClientTicket` + migration (avec contrainte unique `(project_id, number)`)
|
||||
5. API CRUD `ClientTicket` avec sécurité (Provider, Processor, validation `user.client` sur POST, validation des transitions de statut sur PATCH)
|
||||
6. Admin : gestion des utilisateurs-clients (créer un user avec ROLE_CLIENT, lié à un client + projets autorisés)
|
||||
|
||||
### Phase 2 — Portail client
|
||||
|
||||
1. Pages `/portal`, `/portal/projects/{id}`, formulaire de création de ticket
|
||||
2. Upload de documents sur les tickets (réutilisation des composants TaskDocument existants, généralisés avec prop `clientTicketId`)
|
||||
3. Lien `Task.clientTicket` + icône dans le kanban et `/my-tasks` (données via `task:read`)
|
||||
4. Admin : onglet tickets client (liste, changement de statut)
|
||||
|
||||
### Phase 3 — Notifications
|
||||
|
||||
1. Entité `Notification` + API (paginé, 30 par page)
|
||||
2. `MarkAllReadController` — endpoint Symfony custom (`POST /api/notifications/mark-all-read`)
|
||||
3. Auto-création des notifications dans le `ClientTicketNotificationProcessor`
|
||||
4. `NotificationBell.vue` avec polling toutes les 2 minutes
|
||||
5. Composable `useNotifications()`
|
||||
6. Note : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours)
|
||||
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal file
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Task Documents — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs peuvent uploader des fichiers via drag & drop ou sélection, les visualiser (images, PDF) dans une modale plein écran, et les télécharger.
|
||||
|
||||
## Contraintes
|
||||
|
||||
- **Taille max par fichier** : 50 Mo
|
||||
- **Types acceptés** : tous types de fichiers
|
||||
- **Nombre par ticket** : illimité
|
||||
- **Stockage** : filesystem local (`var/uploads/documents/`)
|
||||
- **Permissions** : ROLE_ADMIN pour créer/supprimer, ROLE_USER pour lire
|
||||
- **Contexte** : application single-tenant, tous les utilisateurs voient tous les projets — pas de scoping projet
|
||||
|
||||
## Backend
|
||||
|
||||
### Entité `TaskDocument`
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | int (auto) | Clé primaire |
|
||||
| `task` | ManyToOne → Task | Ticket parent (CASCADE on delete) |
|
||||
| `originalName` | string (255) | Nom original du fichier uploadé |
|
||||
| `fileName` | string (255) | Nom unique sur disque (`{uuid}.{extension}`) |
|
||||
| `mimeType` | string (100) | Type MIME (ex: `image/png`, `application/pdf`) |
|
||||
| `size` | int | Taille en octets |
|
||||
| `createdAt` | DateTimeImmutable | Date d'upload |
|
||||
| `uploadedBy` | ManyToOne → User | Utilisateur ayant uploadé (SET NULL on delete) |
|
||||
|
||||
### Relation inverse sur Task
|
||||
|
||||
- `Task.documents` : OneToMany → TaskDocument, avec `cascade: ['remove']` côté Doctrine
|
||||
- Sérialisé dans le groupe `task:read` pour charger les documents avec le ticket
|
||||
|
||||
### Nettoyage des fichiers à la suppression
|
||||
|
||||
Quand un `TaskDocument` est supprimé (directement ou par cascade depuis Task), le fichier physique doit aussi être supprimé. Stratégie :
|
||||
|
||||
- **Doctrine EntityListener** (`TaskDocumentListener`) avec événement `preRemove`
|
||||
- Récupère le `fileName` de l'entité et supprime le fichier de `var/uploads/documents/`
|
||||
- Si le fichier n'existe pas sur disque (déjà supprimé manuellement), log un warning et continue sans erreur
|
||||
|
||||
Ceci couvre les deux cas :
|
||||
1. Suppression directe d'un document via `DELETE /api/task_documents/{id}`
|
||||
2. Suppression en cascade quand une Task est supprimée
|
||||
|
||||
### Stockage filesystem
|
||||
|
||||
- Répertoire : `var/uploads/documents/`
|
||||
- Nommage : `{uuid}.{extension}` — évite les collisions et les caractères spéciaux
|
||||
- Volume Docker dédié pour persister les uploads
|
||||
- Ajouter `var/uploads/` dans `.gitignore`
|
||||
|
||||
### Téléchargement des fichiers
|
||||
|
||||
Endpoint dédié Symfony servi via un State Provider :
|
||||
|
||||
| Méthode | Route | Description | Accès |
|
||||
|---------|-------|-------------|-------|
|
||||
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier (BinaryFileResponse) | ROLE_USER |
|
||||
|
||||
- Contrôle d'accès via authentification JWT (pas d'accès anonyme)
|
||||
- Retourne le fichier avec les headers `Content-Disposition` (inline pour images/PDF, attachment pour les autres)
|
||||
- Le frontend n'expose jamais le `fileName` interne dans l'URL — utilise l'`id` du document
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Méthode | Route | Description | Accès |
|
||||
|---------|-------|-------------|-------|
|
||||
| `POST` | `/api/task_documents` | Upload multipart/form-data | ROLE_ADMIN |
|
||||
| `GET` | `/api/task_documents?task=/api/tasks/{id}` | Liste documents d'un ticket | ROLE_USER |
|
||||
| `GET` | `/api/task_documents/{id}` | Métadonnées d'un document | ROLE_USER |
|
||||
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier | ROLE_USER |
|
||||
| `DELETE` | `/api/task_documents/{id}` | Supprime document + fichier | ROLE_ADMIN |
|
||||
|
||||
### State Processor — POST (`TaskDocumentProcessor`)
|
||||
|
||||
1. Reçoit le fichier via multipart/form-data + IRI de la task
|
||||
2. Valide : fichier non vide, taille ≤ 50 Mo
|
||||
3. Génère un UUID v4, extrait l'extension du nom original
|
||||
4. Déplace le fichier uploadé dans `var/uploads/documents/{uuid}.{ext}`
|
||||
5. Si le déplacement du fichier échoue, throw une exception — ne pas persister l'entité
|
||||
6. Crée et persiste l'entité `TaskDocument` avec toutes les métadonnées
|
||||
7. Set `uploadedBy` depuis le token JWT courant
|
||||
|
||||
### State Processor — DELETE
|
||||
|
||||
1. Supprime l'entité de la base de données
|
||||
2. Le nettoyage du fichier est géré automatiquement par le `TaskDocumentListener.preRemove`
|
||||
|
||||
### Validation
|
||||
|
||||
- Contrainte sur `originalName` : NotBlank
|
||||
- Contrainte sur `task` : NotNull
|
||||
- Validation dans le Processor : taille fichier ≤ 50 Mo, fichier présent dans la requête
|
||||
- PHP `upload_max_filesize` et `post_max_size` à configurer ≥ 50 Mo
|
||||
|
||||
### Configuration PHP/Nginx
|
||||
|
||||
- `php.ini` : `upload_max_filesize = 50M`, `post_max_size = 55M`
|
||||
- Nginx : `client_max_body_size 55m;`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Placement dans l'UI
|
||||
|
||||
La zone de documents est placée **sous la description** dans le `TaskModal`, visible en mode édition.
|
||||
|
||||
### Composants à créer
|
||||
|
||||
Tous dans `frontend/components/task/` :
|
||||
|
||||
#### `TaskDocumentUpload.vue`
|
||||
|
||||
- Zone drag & drop avec bordure pointillée
|
||||
- Texte : "Glisser des fichiers ici ou cliquer pour sélectionner" (clé i18n : `taskDocuments.dropzone`)
|
||||
- Input file caché (`multiple`, `accept="*"`)
|
||||
- Événements : `dragover`, `dragleave`, `drop` pour le feedback visuel
|
||||
- Barre de progression par fichier pendant l'upload
|
||||
- Upload **séquentiel** (un POST multipart par fichier, un à la fois) — plus simple et prévisible pour les progress bars
|
||||
- Émet un événement quand l'upload est terminé pour rafraîchir la liste
|
||||
|
||||
#### `TaskDocumentList.vue`
|
||||
|
||||
- Grille de cartes compactes pour chaque document
|
||||
- **Images** (`image/*`) : miniature 64x64 en `object-fit: cover`, chargée depuis l'URL de download
|
||||
- Note : les images sont chargées en pleine résolution pour les miniatures. C'est une limitation acceptée — la génération de thumbnails côté serveur pourra être ajoutée ultérieurement si besoin.
|
||||
- **Autres fichiers** : icône selon le type MIME :
|
||||
- PDF → icône PDF
|
||||
- Word/Excel → icônes Office
|
||||
- Archives → icône archive
|
||||
- Défaut → icône fichier générique
|
||||
- Informations affichées : nom original (tronqué si > ~30 chars), taille formatée (Ko/Mo)
|
||||
- Clic sur un document → ouvre `TaskDocumentPreview`
|
||||
- Bouton supprimer (visible uniquement pour ROLE_ADMIN, avec confirmation)
|
||||
|
||||
#### `TaskDocumentPreview.vue`
|
||||
|
||||
- Modale plein écran (overlay sombre semi-transparent)
|
||||
- Contenu selon le type :
|
||||
- **Images** (`image/*`) : `<img>` centré, taille adaptative
|
||||
- **PDF** (`application/pdf`) : `<iframe>` intégré
|
||||
- **Autres** : grande icône + nom du fichier + taille + bouton "Télécharger"
|
||||
- Navigation : flèches gauche/droite pour parcourir les documents du ticket
|
||||
- Fermeture : bouton X en haut à droite, clic sur l'overlay, touche Escape
|
||||
- Raccourcis clavier : flèches pour naviguer, Escape pour fermer
|
||||
|
||||
### Service API
|
||||
|
||||
`frontend/services/task-documents.ts` :
|
||||
|
||||
```typescript
|
||||
getByTask(taskId: number): Promise<TaskDocument[]>
|
||||
upload(taskId: number, file: File): Promise<TaskDocument>
|
||||
remove(id: number): Promise<void>
|
||||
getDownloadUrl(id: number): string // Retourne `/api/task_documents/{id}/download`
|
||||
```
|
||||
|
||||
**Note upload :** la fonction `upload` ne peut pas utiliser `useApi().post()` directement car celui-ci set `Content-Type: application/json`. L'upload doit utiliser `$fetch` directement avec un `FormData` comme body et ne PAS setter de `Content-Type` (le navigateur le fait automatiquement avec le boundary multipart).
|
||||
|
||||
### DTO TypeScript
|
||||
|
||||
`frontend/services/dto/task-document.ts` :
|
||||
|
||||
```typescript
|
||||
type TaskDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
task: string // IRI
|
||||
originalName: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: string | null // IRI ou null si user supprimé
|
||||
}
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
Ajouter dans `frontend/i18n/locales/` :
|
||||
|
||||
```
|
||||
taskDocuments.dropzone → "Glisser des fichiers ici ou cliquer pour sélectionner"
|
||||
taskDocuments.uploaded → "Document uploadé"
|
||||
taskDocuments.deleted → "Document supprimé"
|
||||
taskDocuments.uploadError → "Erreur lors de l'upload"
|
||||
taskDocuments.confirmDelete → "Supprimer ce document ?"
|
||||
taskDocuments.download → "Télécharger"
|
||||
taskDocuments.documents → "Documents"
|
||||
```
|
||||
|
||||
### Intégration dans TaskModal
|
||||
|
||||
- Import des 3 composants dans `TaskModal.vue`
|
||||
- Sous le champ description :
|
||||
1. `TaskDocumentUpload` (si mode édition, ROLE_ADMIN)
|
||||
2. `TaskDocumentList` (toujours visible, passe les documents du ticket)
|
||||
- `TaskDocumentPreview` monté conditionnellement (v-if sur document sélectionné)
|
||||
- Chargement des documents : via la relation `task.documents` déjà sérialisée, ou appel séparé au service
|
||||
|
||||
## Migration
|
||||
|
||||
- Nouvelle table `task_document` avec les colonnes correspondant à l'entité
|
||||
- Index sur `task_id` pour les requêtes filtrées
|
||||
- Clé étrangère `task_id` → `task.id` ON DELETE CASCADE
|
||||
- Clé étrangère `uploaded_by_id` → `user.id` ON DELETE SET NULL
|
||||
|
||||
## Docker
|
||||
|
||||
- Ajouter un volume nommé dans `docker-compose.yml` pour `var/uploads/` afin de persister les fichiers
|
||||
- Le volume est monté dans le service PHP uniquement (pas besoin dans Nginx car les fichiers sont servis via Symfony)
|
||||
- Vérifier la config PHP pour `upload_max_filesize` et `post_max_size`
|
||||
|
||||
## .gitignore
|
||||
|
||||
Ajouter `var/uploads/` dans `.gitignore` pour éviter de committer des fichiers uploadés en dev local.
|
||||
116
frontend/components/admin/AdminBookStackTab.vue
Normal file
116
frontend/components/admin/AdminBookStackTab.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('bookstack.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('bookstack.settings.url')"
|
||||
:placeholder="$t('bookstack.settings.urlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
</p>
|
||||
</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"
|
||||
: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"
|
||||
: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'">
|
||||
{{ testResult ? $t('bookstack.settings.testSuccess') : $t('bookstack.settings.testFailed') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
const form = reactive({
|
||||
url: '',
|
||||
tokenId: '',
|
||||
tokenSecret: '',
|
||||
})
|
||||
|
||||
const hasToken = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.url = settings.url ?? ''
|
||||
hasToken.value = settings.hasToken
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
url: form.url.trim() || null,
|
||||
tokenId: form.tokenId || null,
|
||||
tokenSecret: form.tokenSecret || null,
|
||||
})
|
||||
hasToken.value = result.hasToken
|
||||
form.tokenId = ''
|
||||
form.tokenSecret = ''
|
||||
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>
|
||||
@@ -43,6 +43,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="bookstackShelves.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.bookstackShelfId"
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -53,6 +63,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
@@ -60,8 +81,10 @@
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -89,6 +112,13 @@ const giteaRepoOptions = computed(() =>
|
||||
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
|
||||
)
|
||||
|
||||
const { listShelves } = useBookStackService()
|
||||
const bookstackShelves = ref<BookStackShelf[]>([])
|
||||
|
||||
const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
@@ -96,6 +126,7 @@ const form = reactive({
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -118,6 +149,7 @@ watch(() => props.modelValue, (open) => {
|
||||
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||
: null
|
||||
form.bookstackShelfId = props.project.bookstackShelfId ?? null
|
||||
} else {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
@@ -125,6 +157,7 @@ watch(() => props.modelValue, (open) => {
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
@@ -157,6 +190,15 @@ async function handleSubmit() {
|
||||
payload.giteaRepo = null
|
||||
}
|
||||
|
||||
if (form.bookstackShelfId) {
|
||||
const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId)
|
||||
payload.bookstackShelfId = form.bookstackShelfId
|
||||
payload.bookstackShelfName = shelf?.name ?? null
|
||||
} else {
|
||||
payload.bookstackShelfId = null
|
||||
payload.bookstackShelfName = null
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
@@ -171,11 +213,31 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const newArchived = !props.project.archived
|
||||
await update(props.project.id, { archived: newArchived }, {
|
||||
toastSuccessKey: newArchived ? 'projects.archived' : 'projects.unarchived',
|
||||
})
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
// Gitea not configured, ignore
|
||||
}
|
||||
try {
|
||||
bookstackShelves.value = await listShelves()
|
||||
} catch {
|
||||
// BookStack not configured, ignore
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
158
frontend/components/task/TaskBookStackLinks.vue
Normal file
158
frontend/components/task/TaskBookStackLinks.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<MalioInputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('bookstack.links.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Dropdown results -->
|
||||
<div
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="handleAdd(result)"
|
||||
>
|
||||
<Icon
|
||||
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<span class="truncate">{{ result.name }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Linked documents -->
|
||||
<div v-if="links.length > 0" class="mt-3 space-y-1">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
|
||||
>
|
||||
<Icon
|
||||
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<a
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate text-primary-500 hover:underline"
|
||||
>
|
||||
{{ link.title }}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
@click="handleRemove(link.id)"
|
||||
>
|
||||
<Icon name="mdi:close" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const { getLinks, addLink, removeLink, search } = useBookStackService()
|
||||
|
||||
const links = ref<BookStackLink[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<BookStackSearchResult[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchQuery, (query) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
hasSearched.value = false
|
||||
searchResults.value = []
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isSearching.value = true
|
||||
try {
|
||||
searchResults.value = await search(props.taskId, query.trim())
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
hasSearched.value = true
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function handleAdd(result: BookStackSearchResult) {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
hasSearched.value = false
|
||||
|
||||
// Check if already linked
|
||||
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await addLink(props.taskId, {
|
||||
bookstackId: result.id,
|
||||
bookstackType: result.type,
|
||||
title: result.name,
|
||||
url: result.url,
|
||||
})
|
||||
links.value.unshift(created)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(linkId: number) {
|
||||
try {
|
||||
await removeLink(props.taskId, linkId)
|
||||
links.value = links.value.filter(l => l.id !== linkId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
links.value = await getLinks(props.taskId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
80
frontend/components/task/TaskDocumentList.vue
Normal file
80
frontend/components/task/TaskDocumentList.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="mt-3">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">
|
||||
{{ $t('taskDocuments.title') }} ({{ documents.length }})
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
<!-- Thumbnail or icon -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
defineProps<{
|
||||
documents: TaskDocument[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
preview: [doc: TaskDocument]
|
||||
delete: [doc: TaskDocument]
|
||||
}>()
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getIconForMime(mimeType: string): string {
|
||||
if (mimeType === 'application/pdf') return 'heroicons:document-text'
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
|
||||
return 'heroicons:paper-clip'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
</script>
|
||||
124
frontend/components/task/TaskDocumentPreview.vue
Normal file
124
frontend/components/task/TaskDocumentPreview.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" appear>
|
||||
<div
|
||||
v-if="document"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
|
||||
@click.self="$emit('close')"
|
||||
@keydown.escape="$emit('close')"
|
||||
@keydown.left="$emit('prev')"
|
||||
@keydown.right="$emit('next')"
|
||||
tabindex="0"
|
||||
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"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<button
|
||||
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"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
@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">
|
||||
<!-- Image preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="downloadUrl"
|
||||
:alt="document.originalName"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="downloadUrl"
|
||||
class="h-[85vh] w-[80vw] rounded-lg bg-white"
|
||||
/>
|
||||
|
||||
<!-- Generic file -->
|
||||
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
||||
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- File name footer -->
|
||||
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
document: TaskDocument | null
|
||||
hasPrev: boolean
|
||||
hasNext: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
|
||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
// Focus overlay for keyboard events
|
||||
watch(() => props.document, (doc) => {
|
||||
if (doc) {
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
133
frontend/components/task/TaskDocumentUpload.vue
Normal file
133
frontend/components/task/TaskDocumentUpload.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
|
||||
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
|
||||
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
|
||||
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
|
||||
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="[
|
||||
upload.error ? 'bg-red-500' : upload.uploading ? 'animate-pulse bg-blue-400' : 'bg-green-500',
|
||||
]"
|
||||
:style="{ width: upload.uploading ? '70%' : `${upload.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="upload.error"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="h-5 w-5 shrink-0 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
type UploadState = {
|
||||
name: string
|
||||
progress: number
|
||||
uploading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const uploads = ref<UploadState[]>([])
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.length) {
|
||||
processFiles(Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > maxSize) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.maxSizeError'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const state: UploadState = reactive({
|
||||
name: file.name,
|
||||
progress: 30,
|
||||
uploading: true,
|
||||
error: false,
|
||||
})
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
await uploadFile(props.taskId, file)
|
||||
state.uploading = false
|
||||
state.progress = 100
|
||||
} catch {
|
||||
state.uploading = false
|
||||
state.error = true
|
||||
state.progress = 100
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.uploadError'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('uploaded')
|
||||
}
|
||||
|
||||
// Clean up completed uploads after a delay
|
||||
setTimeout(() => {
|
||||
uploads.value = uploads.value.filter(u => u.error)
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ $t('gitea.error') }}</p>
|
||||
<p class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
@@ -248,7 +248,7 @@ const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
@@ -338,7 +338,7 @@ async function loadData() {
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = false
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
@@ -346,8 +346,8 @@ async function loadData() {
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch {
|
||||
error.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-8 py-5">
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-8 py-6">
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -49,7 +49,7 @@
|
||||
/>
|
||||
|
||||
<!-- Two-column selects -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<MalioSelect
|
||||
v-model="form.statusId"
|
||||
:options="statusOptions"
|
||||
@@ -121,6 +121,30 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentUpload
|
||||
v-if="isEditing && task && isAdmin"
|
||||
:task-id="task.id"
|
||||
@uploaded="handleDocumentUploaded"
|
||||
/>
|
||||
<TaskDocumentList
|
||||
v-if="isEditing && task"
|
||||
:documents="documents"
|
||||
:is-admin="isAdmin"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
/>
|
||||
|
||||
<!-- Document preview modal -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < documents.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Git section -->
|
||||
<TaskGitSection
|
||||
v-if="hasGitea && isEditing && task"
|
||||
@@ -128,6 +152,12 @@
|
||||
:gitea-url="giteaUrl"
|
||||
/>
|
||||
|
||||
<!-- BookStack links -->
|
||||
<TaskBookStackLinks
|
||||
v-if="hasBookStack && isEditing && task"
|
||||
:task-id="task.id"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
@@ -183,6 +213,12 @@
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Confirm delete document modal -->
|
||||
<ConfirmDeleteDocumentModal
|
||||
v-model="confirmDeleteDocOpen"
|
||||
@confirm="confirmDeleteDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -191,7 +227,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
@@ -237,6 +276,10 @@ const hasGitea = computed(() => {
|
||||
return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value
|
||||
})
|
||||
|
||||
const hasBookStack = computed(() => {
|
||||
return !!props.task?.project?.bookstackShelfId
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -339,6 +382,66 @@ watch(() => props.modelValue, async (open) => {
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const documents = computed(() => localDocuments.value)
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
// Sync documents from task prop when modal opens or task changes
|
||||
watch(() => props.task?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.task) return
|
||||
localDocuments.value = await getDocumentsByTask(props.task.id)
|
||||
}
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
previewDoc.value = doc
|
||||
}
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = documents.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < documents.value.length - 1) {
|
||||
previewDoc.value = documents.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteDocOpen = ref(false)
|
||||
const documentToDelete = ref<TaskDocument | null>(null)
|
||||
|
||||
function handleDeleteDocument(doc: TaskDocument) {
|
||||
documentToDelete.value = doc
|
||||
confirmDeleteDocOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteDocument() {
|
||||
if (!documentToDelete.value) return
|
||||
await removeDocument(documentToDelete.value.id)
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function handleDocumentUploaded() {
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.task) return
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative rounded-lg border border-neutral-200 bg-white">
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Day headers -->
|
||||
<div
|
||||
class="sticky z-20 flex border-b border-neutral-200 bg-white"
|
||||
:style="{ top: `${stickyOffset}px` }"
|
||||
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg"
|
||||
>
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
@@ -22,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Grid body -->
|
||||
<div ref="gridBodyEl" class="relative flex">
|
||||
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Hour labels -->
|
||||
<div class="w-16 shrink-0">
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||
<div class="flex h-full items-center justify-end">
|
||||
<div class="flex gap-12 text-xl text-white">
|
||||
<div class="group relative flex gap-4">
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<button
|
||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
||||
@click="ui.openMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
@@ -34,6 +40,7 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
|
||||
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $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"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -37,7 +37,7 @@
|
||||
<slot name="actions" :item="item" />
|
||||
<button
|
||||
v-if="deletable"
|
||||
class="text-[red-500] hover:text-[red-700]"
|
||||
class="text-neutral-400 transition-colors hover:text-red-500"
|
||||
@click.stop="$emit('delete', item)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white transition"
|
||||
:class="timerStore.isRunning
|
||||
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||
: 'bg-primary-500 hover:bg-primary-600'"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
|
||||
:class="[
|
||||
timerStore.isRunning
|
||||
? 'bg-[#F18619] hover:bg-[#d97314]'
|
||||
: 'bg-primary-500 hover:bg-primary-600',
|
||||
collapsed ? 'px-2' : 'px-4'
|
||||
]"
|
||||
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||
>
|
||||
<Icon
|
||||
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
|
||||
size="16"
|
||||
:size="collapsed ? '20' : '16'"
|
||||
/>
|
||||
<span v-if="!collapsed" class="font-mono tracking-wide">
|
||||
{{ timerStore.elapsedFormatted }}
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
"projects": {
|
||||
"created": "Projet créé avec succès.",
|
||||
"updated": "Projet mis à jour avec succès.",
|
||||
"deleted": "Projet supprimé avec succès."
|
||||
"deleted": "Projet supprimé avec succès.",
|
||||
"archived": "Projet archivé avec succès.",
|
||||
"unarchived": "Projet désarchivé avec succès.",
|
||||
"showArchived": "Voir les projets archivés",
|
||||
"hideArchived": "Masquer les projets archivés"
|
||||
},
|
||||
"taskStatuses": {
|
||||
"created": "Statut créé avec succès.",
|
||||
@@ -56,6 +60,17 @@
|
||||
"archived": "Groupe archivé avec succès.",
|
||||
"unarchived": "Groupe désarchivé avec succès."
|
||||
},
|
||||
"taskDocuments": {
|
||||
"title": "Documents",
|
||||
"dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner",
|
||||
"uploaded": "Document ajouté avec succès.",
|
||||
"deleted": "Document supprimé avec succès.",
|
||||
"uploadError": "Erreur lors de l'upload du document.",
|
||||
"confirmDeleteTitle": "Supprimer le document",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
|
||||
"download": "Télécharger",
|
||||
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
|
||||
},
|
||||
"tasks": {
|
||||
"created": "Ticket créé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
@@ -99,6 +114,53 @@
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"noData": "Aucune donnée",
|
||||
"noPriority": "Sans priorité",
|
||||
"noProject": "Sans projet",
|
||||
"hoursWorked": "Heures travaillées",
|
||||
"inProgress": "En cours",
|
||||
"done": "Terminé",
|
||||
"filters": {
|
||||
"period": "Période",
|
||||
"project": "Projet",
|
||||
"user": "Utilisateur",
|
||||
"allProjects": "Tous les projets",
|
||||
"allUsers": "Tous les utilisateurs"
|
||||
},
|
||||
"periods": {
|
||||
"thisWeek": "Cette semaine",
|
||||
"lastWeek": "Semaine dernière",
|
||||
"thisMonth": "Ce mois",
|
||||
"lastMonth": "Mois dernier"
|
||||
},
|
||||
"stats": {
|
||||
"hoursPeriod": "Heures sur la période",
|
||||
"myActiveTasks": "Mes tâches actives",
|
||||
"completed": "terminée(s)",
|
||||
"totalTasks": "Tâches totales",
|
||||
"unassigned": "non assignée(s)",
|
||||
"projects": "Projets",
|
||||
"users": "utilisateur(s)"
|
||||
},
|
||||
"charts": {
|
||||
"hoursByDay": "Heures par jour",
|
||||
"hoursByProject": "Temps par projet",
|
||||
"tasksByStatus": "Tâches par statut",
|
||||
"tasksByPriority": "Tâches par priorité",
|
||||
"tasksByProject": "Tâches par projet"
|
||||
},
|
||||
"days": {
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Jeu",
|
||||
"fri": "Ven",
|
||||
"sat": "Sam",
|
||||
"sun": "Dim"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
},
|
||||
@@ -150,5 +212,28 @@
|
||||
},
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"bookstack": {
|
||||
"settings": {
|
||||
"title": "Configuration BookStack",
|
||||
"url": "URL du serveur",
|
||||
"urlPlaceholder": "https://wiki.example.com",
|
||||
"tokenId": "Token ID",
|
||||
"tokenIdPlaceholder": "Entrez le Token ID",
|
||||
"tokenSecret": "Token Secret",
|
||||
"tokenSecretPlaceholder": "Entrez le Token Secret",
|
||||
"tokenConfigured": "Token configuré",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration BookStack sauvegardée.",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie.",
|
||||
"testFailed": "Connexion échouée."
|
||||
},
|
||||
"links": {
|
||||
"title": "Documentation",
|
||||
"searchPlaceholder": "Rechercher une page ou un livre...",
|
||||
"noResults": "Aucun résultat",
|
||||
"empty": "Aucun document lié"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-all duration-300"
|
||||
:class="ui.sidebarCollapsed ? 'w-16' : 'w-64'"
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="[
|
||||
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center overflow-hidden" :class="ui.sidebarCollapsed ? 'p-2' : ''">
|
||||
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
|
||||
<img
|
||||
v-if="!ui.sidebarCollapsed"
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
alt="Logo"
|
||||
class="w-auto"
|
||||
@@ -18,49 +30,61 @@
|
||||
alt="Logo"
|
||||
class="h-8 w-8 object-cover object-left"
|
||||
/>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-hidden" :class="ui.sidebarCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<SidebarLink
|
||||
to="/"
|
||||
icon="mdi:question-mark"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
label="Tableau de bord"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
label="Projets"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}`"
|
||||
icon="mdi:view-column-outline"
|
||||
label="Kanban"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
exact
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/groups`"
|
||||
icon="mdi:tag-multiple-outline"
|
||||
label="Groupes"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/archives`"
|
||||
icon="mdi:archive-outline"
|
||||
label="Archives"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
</template>
|
||||
@@ -68,24 +92,26 @@
|
||||
to="/time-tracking"
|
||||
icon="mdi:clock-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3">
|
||||
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
|
||||
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p v-if="!ui.sidebarCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors"
|
||||
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
@@ -99,8 +125,8 @@
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex-1 overflow-y-auto bg-white px-16 pb-24">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 bg-white" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -129,6 +155,17 @@ const ui = useUiStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
return ui.sidebarCollapsed
|
||||
})
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
|
||||
const currentProjectId = computed(() => {
|
||||
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||
return match ? match[1] : null
|
||||
@@ -211,3 +248,14 @@ const handleLogout = async () => {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
123
frontend/package-lock.json
generated
123
frontend/package-lock.json
generated
@@ -12,10 +12,12 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
},
|
||||
@@ -72,6 +74,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1027,7 +1030,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
@@ -1037,7 +1039,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"debug": "^4.3.1",
|
||||
@@ -1052,7 +1053,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
},
|
||||
@@ -1065,7 +1065,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
@@ -1078,7 +1077,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
@@ -1088,7 +1086,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"levn": "^0.4.1"
|
||||
@@ -1102,7 +1099,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
@@ -1112,7 +1108,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
@@ -1126,7 +1121,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
},
|
||||
@@ -1140,7 +1134,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
@@ -2118,6 +2111,12 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
@@ -2414,6 +2413,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
||||
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -2486,6 +2486,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
||||
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.27",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3132,6 +3133,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
||||
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.95.0"
|
||||
},
|
||||
@@ -5237,8 +5239,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -5250,8 +5251,7 @@
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
@@ -5586,6 +5586,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
@@ -5779,6 +5780,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5818,7 +5820,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6150,6 +6151,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -6343,6 +6345,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6471,6 +6474,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6607,6 +6611,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -6636,6 +6653,7 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
@@ -7169,8 +7187,7 @@
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
@@ -7670,7 +7687,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -7701,7 +7717,6 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -7714,7 +7729,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -7727,7 +7741,6 @@
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
@@ -7740,7 +7753,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -7750,7 +7762,6 @@
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
@@ -7768,7 +7779,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -7794,7 +7804,6 @@
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
@@ -7807,7 +7816,6 @@
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
@@ -7908,8 +7916,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -7937,15 +7944,13 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-npm-meta": {
|
||||
"version": "1.4.0",
|
||||
@@ -7990,7 +7995,6 @@
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
@@ -8021,7 +8025,6 @@
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
@@ -8038,7 +8041,6 @@
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
@@ -8051,8 +8053,7 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
@@ -8585,7 +8586,6 @@
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
@@ -8962,22 +8962,19 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -9055,7 +9052,6 @@
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
@@ -9316,7 +9312,6 @@
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
@@ -9401,7 +9396,6 @@
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
@@ -9793,8 +9787,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
@@ -10030,6 +10023,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
||||
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.3.2",
|
||||
"@nuxt/cli": "^3.33.0",
|
||||
@@ -10300,7 +10294,6 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -10352,6 +10345,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -10435,7 +10429,6 @@
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -10451,7 +10444,6 @@
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
@@ -10494,7 +10486,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -10598,6 +10589,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -10714,6 +10706,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11257,6 +11250,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -11307,7 +11301,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
@@ -11344,7 +11337,6 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -11706,6 +11698,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -12488,6 +12481,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12828,7 +12822,6 @@
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
@@ -12896,6 +12889,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13331,7 +13325,6 @@
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
@@ -13356,6 +13349,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -13717,6 +13711,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
@@ -13742,6 +13737,16 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-devtools-stub": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
|
||||
@@ -13753,6 +13758,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
@@ -13774,6 +13780,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -13826,7 +13833,6 @@
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13995,7 +14001,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-500">Administration</h1>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
|
||||
|
||||
<div class="mt-6 border-b border-neutral-200">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div>
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||
@@ -26,6 +28,7 @@
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +44,7 @@ const tabs = [
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -1,7 +1,668 @@
|
||||
<template>
|
||||
<h1 class="text-primary-500">Tableau de bord</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doughnut, Bar, Line } from 'vue-chartjs'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('dashboard.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const timeEntryService = useTimeEntryService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
|
||||
const allTasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const allTimeEntries = ref<TimeEntry[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// ── Filters ──
|
||||
|
||||
type PeriodKey = 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
|
||||
|
||||
const selectedPeriod = ref<PeriodKey>('thisWeek')
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
|
||||
const periodOptions = computed(() => [
|
||||
{ label: t('dashboard.periods.thisWeek'), value: 'thisWeek' },
|
||||
{ label: t('dashboard.periods.lastWeek'), value: 'lastWeek' },
|
||||
{ label: t('dashboard.periods.thisMonth'), value: 'thisMonth' },
|
||||
{ label: t('dashboard.periods.lastMonth'), value: 'lastMonth' },
|
||||
])
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// ── Period date ranges ──
|
||||
|
||||
function getWeekRange(offset: number = 0) {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() + diffToMonday + offset * 7)
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
sunday.setHours(23, 59, 59, 999)
|
||||
return { start: monday, end: sunday }
|
||||
}
|
||||
|
||||
function getMonthRange(offset: number = 0) {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const dateRange = computed(() => {
|
||||
switch (selectedPeriod.value) {
|
||||
case 'thisWeek': return getWeekRange(0)
|
||||
case 'lastWeek': return getWeekRange(-1)
|
||||
case 'thisMonth': return getMonthRange(0)
|
||||
case 'lastMonth': return getMonthRange(-1)
|
||||
}
|
||||
})
|
||||
|
||||
const isWeekPeriod = computed(() =>
|
||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||
)
|
||||
|
||||
// ── Filtered data (client-side project filter) ──
|
||||
|
||||
const tasks = computed(() => {
|
||||
if (!selectedProjectId.value) return allTasks.value
|
||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
||||
})
|
||||
|
||||
const timeEntries = computed(() => {
|
||||
if (!selectedProjectId.value) return allTimeEntries.value
|
||||
return allTimeEntries.value.filter(e => e.project?.id === selectedProjectId.value)
|
||||
})
|
||||
|
||||
// ── Data loading ──
|
||||
|
||||
async function loadReferenceData() {
|
||||
const [s, p, proj, u] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
priorityService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
priorities.value = p
|
||||
projects.value = proj
|
||||
users.value = u
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
allTasks.value = await taskService.getFiltered({ archived: false })
|
||||
}
|
||||
|
||||
async function loadTimeEntries() {
|
||||
const params: { after: string; before: string; user?: number } = {
|
||||
after: dateRange.value.start.toISOString(),
|
||||
before: dateRange.value.end.toISOString(),
|
||||
}
|
||||
if (selectedUserId.value) {
|
||||
params.user = selectedUserId.value
|
||||
}
|
||||
allTimeEntries.value = await timeEntryService.getByDateRange(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks(), loadTimeEntries()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reload time entries when period or user changes (server-side filter)
|
||||
watch([selectedPeriod, selectedUserId], () => {
|
||||
loadTimeEntries()
|
||||
})
|
||||
|
||||
onMounted(() => loadAll())
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function durationHours(entry: TimeEntry): number {
|
||||
const start = new Date(entry.startedAt)
|
||||
const end = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
|
||||
return (end.getTime() - start.getTime()) / 3_600_000
|
||||
}
|
||||
|
||||
function formatHours(h: number): string {
|
||||
const hours = Math.floor(h)
|
||||
const mins = Math.round((h - hours) * 60)
|
||||
return mins > 0 ? `${hours}h${String(mins).padStart(2, '0')}` : `${hours}h`
|
||||
}
|
||||
|
||||
// ── KPI Stats ──
|
||||
|
||||
const totalHoursThisWeek = computed(() =>
|
||||
timeEntries.value.reduce((sum, e) => sum + durationHours(e), 0)
|
||||
)
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
||||
)
|
||||
|
||||
const myTasksDone = computed(() =>
|
||||
myTasks.value.filter(t => t.status?.isFinal)
|
||||
)
|
||||
|
||||
const unassignedTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.assignee)
|
||||
)
|
||||
|
||||
// ── Chart: Tasks by Status (Doughnut) ──
|
||||
|
||||
const tasksByStatusData = computed(() => {
|
||||
const sorted = [...statuses.value].sort((a, b) => a.position - b.position)
|
||||
const noStatus = tasks.value.filter(t => !t.status).length
|
||||
const labels = noStatus > 0 ? ['Backlog', ...sorted.map(s => s.label)] : sorted.map(s => s.label)
|
||||
const data = noStatus > 0
|
||||
? [noStatus, ...sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)]
|
||||
: sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)
|
||||
const colors = noStatus > 0
|
||||
? ['#9ca3af', ...sorted.map(s => s.color)]
|
||||
: sorted.map(s => s.color)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Tasks by Priority (Bar) ──
|
||||
|
||||
const tasksByPriorityData = computed(() => {
|
||||
const sorted = [...priorities.value]
|
||||
const noPriority = tasks.value.filter(t => !t.priority).length
|
||||
const labels = [...sorted.map(p => p.label), ...(noPriority > 0 ? [t('dashboard.noPriority')] : [])]
|
||||
const data = [...sorted.map(p => tasks.value.filter(t => t.priority?.id === p.id).length), ...(noPriority > 0 ? [noPriority] : [])]
|
||||
const colors = [...sorted.map(p => p.color), ...(noPriority > 0 ? ['#9ca3af'] : [])]
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Hours by Project (Doughnut) ──
|
||||
|
||||
const hoursByProjectData = computed(() => {
|
||||
const projectHours = new Map<number, { name: string; color: string; hours: number }>()
|
||||
let noProjectHours = 0
|
||||
|
||||
for (const entry of timeEntries.value) {
|
||||
const h = durationHours(entry)
|
||||
if (entry.project) {
|
||||
const existing = projectHours.get(entry.project.id)
|
||||
if (existing) {
|
||||
existing.hours += h
|
||||
} else {
|
||||
projectHours.set(entry.project.id, {
|
||||
name: entry.project.name,
|
||||
color: entry.project.color || '#6366f1',
|
||||
hours: h,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
noProjectHours += h
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...projectHours.values()].sort((a, b) => b.hours - a.hours)
|
||||
if (noProjectHours > 0) {
|
||||
entries.push({ name: t('dashboard.noProject'), color: '#9ca3af', hours: noProjectHours })
|
||||
}
|
||||
|
||||
return {
|
||||
labels: entries.map(e => e.name),
|
||||
datasets: [{
|
||||
data: entries.map(e => Math.round(e.hours * 100) / 100),
|
||||
backgroundColor: entries.map(e => e.color),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Hours by Day (Line) ──
|
||||
|
||||
const weekDayLabels = [
|
||||
t('dashboard.days.mon'),
|
||||
t('dashboard.days.tue'),
|
||||
t('dashboard.days.wed'),
|
||||
t('dashboard.days.thu'),
|
||||
t('dashboard.days.fri'),
|
||||
t('dashboard.days.sat'),
|
||||
t('dashboard.days.sun'),
|
||||
]
|
||||
|
||||
const hoursByDayData = computed(() => {
|
||||
if (isWeekPeriod.value) {
|
||||
const dayHours = new Array(7).fill(0)
|
||||
for (const entry of timeEntries.value) {
|
||||
const start = new Date(entry.startedAt)
|
||||
const dayIndex = start.getDay() === 0 ? 6 : start.getDay() - 1
|
||||
dayHours[dayIndex] += durationHours(entry)
|
||||
}
|
||||
return {
|
||||
labels: weekDayLabels,
|
||||
datasets: [{
|
||||
label: t('dashboard.hoursWorked'),
|
||||
data: dayHours.map(h => Math.round(h * 100) / 100),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
pointRadius: 4,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// Month view: group by week number
|
||||
const { start, end } = dateRange.value
|
||||
const weekMap = new Map<string, number>()
|
||||
const weekLabels: string[] = []
|
||||
|
||||
// Build week labels for the month
|
||||
const cursor = new Date(start)
|
||||
while (cursor <= end) {
|
||||
const weekStart = new Date(cursor)
|
||||
const weekEnd = new Date(cursor)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
if (weekEnd > end) weekEnd.setTime(end.getTime())
|
||||
const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1} - ${weekEnd.getDate()}/${weekEnd.getMonth() + 1}`
|
||||
weekLabels.push(label)
|
||||
weekMap.set(label, 0)
|
||||
cursor.setDate(cursor.getDate() + 7)
|
||||
// Align to Monday
|
||||
const d = cursor.getDay()
|
||||
if (d !== 1) {
|
||||
cursor.setDate(cursor.getDate() + (d === 0 ? 1 : 8 - d))
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of timeEntries.value) {
|
||||
const entryDate = new Date(entry.startedAt)
|
||||
for (let i = 0; i < weekLabels.length; i++) {
|
||||
const parts = weekLabels[i].split(' - ')
|
||||
const [sd, sm] = parts[0].split('/').map(Number)
|
||||
const [ed, em] = parts[1].split('/').map(Number)
|
||||
const ws = new Date(start.getFullYear(), sm - 1, sd)
|
||||
const we = new Date(start.getFullYear(), em - 1, ed, 23, 59, 59)
|
||||
if (entryDate >= ws && entryDate <= we) {
|
||||
weekMap.set(weekLabels[i], (weekMap.get(weekLabels[i]) ?? 0) + durationHours(entry))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: weekLabels,
|
||||
datasets: [{
|
||||
label: t('dashboard.hoursWorked'),
|
||||
data: weekLabels.map(l => Math.round((weekMap.get(l) ?? 0) * 100) / 100),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
pointRadius: 4,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart: Tasks by Project (Horizontal Bar) ──
|
||||
|
||||
const tasksByProjectData = computed(() => {
|
||||
const projectTasks = new Map<number, { name: string; color: string; count: number; done: number }>()
|
||||
|
||||
for (const task of tasks.value) {
|
||||
if (!task.project) continue
|
||||
const existing = projectTasks.get(task.project.id)
|
||||
const isDone = task.status?.isFinal ?? false
|
||||
if (existing) {
|
||||
existing.count++
|
||||
if (isDone) existing.done++
|
||||
} else {
|
||||
projectTasks.set(task.project.id, {
|
||||
name: task.project.name,
|
||||
color: task.project.color || '#6366f1',
|
||||
count: 1,
|
||||
done: isDone ? 1 : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...projectTasks.values()].sort((a, b) => b.count - a.count)
|
||||
|
||||
return {
|
||||
labels: entries.map(e => e.name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.inProgress'),
|
||||
data: entries.map(e => e.count - e.done),
|
||||
backgroundColor: entries.map(e => e.color),
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.done'),
|
||||
data: entries.map(e => e.done),
|
||||
backgroundColor: entries.map(e => e.color + '66'),
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// ── Chart options ──
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const barOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { stepSize: 1 },
|
||||
grid: { color: '#f3f4f6' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const horizontalBarOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y' as const,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true,
|
||||
ticks: { stepSize: 1 },
|
||||
grid: { color: '#f3f4f6' },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => `${formatHours(ctx.raw)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f3f4f6' },
|
||||
ticks: {
|
||||
callback: (value: any) => `${value}h`,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
min-width="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||
<p class="text-neutral-400">{{ $t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- KPI Cards -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.hoursPeriod') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ formatHours(totalHoursThisWeek) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.myActiveTasks') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ myTasks.length - myTasksDone.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ myTasksDone.length }} {{ $t('dashboard.stats.completed') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.totalTasks') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ tasks.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ unassignedTasks.length }} {{ $t('dashboard.stats.unassigned') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.projects') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
{{ projects.length }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ users.length }} {{ $t('dashboard.stats.users') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<!-- Hours by Day (Line) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.hoursByDay') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Line :data="hoursByDayData" :options="lineOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hours by Project (Doughnut) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.hoursByProject') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Doughnut
|
||||
v-if="hoursByProjectData.labels.length > 0"
|
||||
:data="hoursByProjectData"
|
||||
:options="doughnutOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<!-- Tasks by Status (Doughnut) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByStatus') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Doughnut
|
||||
v-if="tasksByStatusData.labels.length > 0"
|
||||
:data="tasksByStatusData"
|
||||
:options="doughnutOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks by Priority (Bar) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByPriority') }}
|
||||
</h2>
|
||||
<div class="mt-4 h-64">
|
||||
<Bar
|
||||
v-if="tasksByPriorityData.labels.length > 0"
|
||||
:data="tasksByPriorityData"
|
||||
:options="barOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 3 -->
|
||||
<div class="mt-6">
|
||||
<!-- Tasks by Project (Horizontal Bar) -->
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<h2 class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('dashboard.charts.tasksByProject') }}
|
||||
</h2>
|
||||
<div class="mt-4" :style="{ height: Math.max(200, tasksByProjectData.labels.length * 40 + 60) + 'px' }">
|
||||
<Bar
|
||||
v-if="tasksByProjectData.labels.length > 0"
|
||||
:data="tasksByProjectData"
|
||||
:options="horizontalBarOptions"
|
||||
/>
|
||||
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
|
||||
{{ $t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -230,103 +230,135 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewKanban')"
|
||||
@click="viewMode = 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-column-outline" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="20" />
|
||||
</button>
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewKanban')"
|
||||
@click="viewMode = 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-column-outline" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<!-- Backlog column (tasks without status) -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog below kanban -->
|
||||
<div
|
||||
v-if="backlogTasks.length > 0"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
|
||||
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
@@ -334,39 +366,12 @@ onMounted(() => {
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status columns -->
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
<p
|
||||
v-if="backlogTasks.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,12 +380,12 @@ onMounted(() => {
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
|
||||
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div>
|
||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||
{{ $t('archive.empty') }}
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} — Groupes</h1>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div>
|
||||
<ProjectGroupTab :project-id="projectId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }}</h1>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
+ Ajouter un ticket
|
||||
</button>
|
||||
</div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
@@ -93,55 +96,14 @@
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-neutral-900">Backlog</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
<h2 class="text-lg font-bold text-neutral-900">Backlog ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
|
||||
draggable="true"
|
||||
@dragstart="onBacklogDragStart($event, task)"
|
||||
@dragend="onBacklogDragEnd"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.effort"
|
||||
class="text-sm font-bold text-neutral-700"
|
||||
>
|
||||
{{ task.effort.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignee"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
|
||||
:title="task.assignee.username"
|
||||
>
|
||||
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,15 +269,6 @@ function onDrop(event: DragEvent) {
|
||||
return Number(event.dataTransfer!.getData('text/plain'))
|
||||
}
|
||||
|
||||
function onBacklogDragStart(event: DragEvent, task: Task) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(task.id))
|
||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||
}
|
||||
|
||||
function onBacklogDragEnd(event: DragEvent) {
|
||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||
}
|
||||
|
||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||
const taskId = onDrop(event)
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">Projets</h1>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un projet
|
||||
</button>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
|
||||
<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'"
|
||||
@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"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un projet</span>
|
||||
<span class="sm:hidden">+ Projet</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
|
||||
:class="{ 'opacity-60': project.archived }"
|
||||
@click="navigateTo(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-md font-bold" :style="{ color: project.color }">{{ project.name }}</h3>
|
||||
<span
|
||||
v-if="project.archived"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
Archivé
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-neutral-400 hover:text-primary-500"
|
||||
@@ -37,7 +59,7 @@
|
||||
v-if="projects.length === 0 && !isLoading"
|
||||
class="col-span-full py-12 text-center text-neutral-400"
|
||||
>
|
||||
Aucun projet trouvé.
|
||||
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,12 +88,13 @@ const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, c] = await Promise.all([
|
||||
projectService.getAll(),
|
||||
projectService.getAll({ archived: showArchived.value }),
|
||||
clientService.getAll(),
|
||||
])
|
||||
projects.value = p
|
||||
@@ -81,6 +104,11 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleArchived() {
|
||||
showArchived.value = !showArchived.value
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedProject.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
@@ -1,72 +1,79 @@
|
||||
<template>
|
||||
<div>
|
||||
<div ref="pageHeaderEl" class="sticky top-0 z-40 bg-white pb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">Suivi des temps</h1>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<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="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
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"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
+ Ajouter une Activité
|
||||
<span class="hidden sm:inline">+ Ajouter une Activité</span>
|
||||
<span class="sm:hidden">+ Activité</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex items-center gap-4">
|
||||
<h2 class="text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="px-3 py-1 text-sm font-semibold transition"
|
||||
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200">
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="px-3 py-1 text-sm font-semibold transition"
|
||||
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
/>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mt-4 -mb-24 min-h-0 flex-1">
|
||||
<TimeEntryList
|
||||
v-if="viewMode === 'list'"
|
||||
:entries="filteredEntries"
|
||||
|
||||
28
frontend/plugins/chartjs.client.ts
Normal file
28
frontend/plugins/chartjs.client.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Filler,
|
||||
} from 'chart.js'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Filler,
|
||||
)
|
||||
})
|
||||
66
frontend/services/bookstack.ts
Normal file
66
frontend/services/bookstack.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
BookStackSettings,
|
||||
BookStackSettingsWrite,
|
||||
BookStackTestResult,
|
||||
BookStackShelf,
|
||||
BookStackLink,
|
||||
BookStackLinkCreate,
|
||||
BookStackSearchResult,
|
||||
} from './dto/bookstack'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useBookStackService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<BookStackSettings> {
|
||||
return api.get<BookStackSettings>('/settings/bookstack')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings> {
|
||||
return api.put<BookStackSettings>('/settings/bookstack', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'bookstack.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<BookStackTestResult> {
|
||||
return api.post<BookStackTestResult>('/settings/bookstack/test')
|
||||
}
|
||||
|
||||
async function listShelves(): Promise<BookStackShelf[]> {
|
||||
const data = await api.get<HydraCollection<BookStackShelf>>('/bookstack/shelves')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getLinks(taskId: number): Promise<BookStackLink[]> {
|
||||
const data = await api.get<HydraCollection<BookStackLink>>(`/tasks/${taskId}/bookstack/links`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink> {
|
||||
return api.post<BookStackLink>(`/tasks/${taskId}/bookstack/links`, payload as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function removeLink(taskId: number, linkId: number): Promise<void> {
|
||||
await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`)
|
||||
}
|
||||
|
||||
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]> {
|
||||
const data = await api.get<HydraCollection<BookStackSearchResult>>(
|
||||
`/tasks/${taskId}/bookstack/search`,
|
||||
{ q: query },
|
||||
)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
return {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
listShelves,
|
||||
getLinks,
|
||||
addLink,
|
||||
removeLink,
|
||||
search,
|
||||
}
|
||||
}
|
||||
42
frontend/services/dto/bookstack.ts
Normal file
42
frontend/services/dto/bookstack.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type BookStackSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type BookStackSettingsWrite = {
|
||||
url: string | null
|
||||
tokenId: string | null
|
||||
tokenSecret: string | null
|
||||
}
|
||||
|
||||
export type BookStackTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type BookStackShelf = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BookStackLink = {
|
||||
id: number
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type BookStackLinkCreate = {
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BookStackSearchResult = {
|
||||
id: number
|
||||
type: 'page' | 'book'
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export type Project = {
|
||||
client: Client | null
|
||||
giteaOwner: string | null
|
||||
giteaRepo: string | null
|
||||
bookstackShelfId: number | null
|
||||
bookstackShelfName: string | null
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
@@ -20,4 +23,7 @@ export type ProjectWrite = {
|
||||
client: string | null // IRI : "/api/clients/1" ou null
|
||||
giteaOwner?: string | null
|
||||
giteaRepo?: string | null
|
||||
bookstackShelfId?: number | null
|
||||
bookstackShelfName?: string | null
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
13
frontend/services/dto/task-document.ts
Normal file
13
frontend/services/dto/task-document.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { UserData } from './user-data'
|
||||
|
||||
export type TaskDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
task: string
|
||||
originalName: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: UserData | null
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { TaskTag } from './task-tag'
|
||||
import type { TaskGroup } from './task-group'
|
||||
import type { UserData } from './user-data'
|
||||
import type { Project } from './project'
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type Task = {
|
||||
id: number
|
||||
@@ -19,6 +20,7 @@ export type Task = {
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { extractHydraMembers } from '~/utils/api'
|
||||
export function useProjectService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Project[]> {
|
||||
const data = await api.get<HydraCollection<Project>>('/projects')
|
||||
async function getAll(params?: { archived?: boolean }): Promise<Project[]> {
|
||||
const query = params?.archived !== undefined ? `?archived=${params.archived}` : ''
|
||||
const data = await api.get<HydraCollection<Project>>(`/projects${query}`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
@@ -20,9 +21,9 @@ export function useProjectService() {
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ProjectWrite>): Promise<Project> {
|
||||
async function update(id: number, payload: Partial<ProjectWrite>, options?: { toastSuccessKey?: string }): Promise<Project> {
|
||||
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'projects.updated',
|
||||
toastSuccessKey: options?.toastSuccessKey ?? 'projects.updated',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
42
frontend/services/task-documents.ts
Normal file
42
frontend/services/task-documents.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { TaskDocument } from './dto/task-document'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
export function useTaskDocumentService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
|
||||
async function getByTask(taskId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
task: `/api/tasks/${taskId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function upload(taskId: number, file: File): Promise<TaskDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('task', `/api/tasks/${taskId}`)
|
||||
|
||||
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
// Do NOT set Content-Type — browser sets multipart boundary automatically
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
function getDownloadUrl(id: number): string {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByTask, upload, remove, getDownloadUrl }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem('ui-sidebar-collapsed')
|
||||
@@ -18,5 +19,13 @@ export const useUiStore = defineStore('ui', () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, toggleSidebar }
|
||||
function openMobileSidebar() {
|
||||
sidebarOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, sidebarOpen, toggleSidebar, openMobileSidebar, closeMobileSidebar }
|
||||
})
|
||||
|
||||
31
migrations/Version20260314075537.php
Normal file
31
migrations/Version20260314075537.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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 Version20260314075537 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('ALTER TABLE project ADD archived BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE project DROP archived');
|
||||
}
|
||||
}
|
||||
39
migrations/Version20260315170358.php
Normal file
39
migrations/Version20260315170358.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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 Version20260315170358 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_document (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, original_name VARCHAR(255) NOT NULL, file_name VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, task_id INT NOT NULL, uploaded_by_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_98A9603A8DB60186 ON task_document (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_98A9603AA2B28FE8 ON task_document (uploaded_by_id)');
|
||||
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A8DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603AA2B28FE8 FOREIGN KEY (uploaded_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE project ALTER archived DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A8DB60186');
|
||||
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603AA2B28FE8');
|
||||
$this->addSql('DROP TABLE task_document');
|
||||
$this->addSql('ALTER TABLE project ALTER archived SET DEFAULT false');
|
||||
}
|
||||
}
|
||||
42
migrations/Version20260315170552.php
Normal file
42
migrations/Version20260315170552.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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 Version20260315170552 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 book_stack_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, url VARCHAR(255) DEFAULT NULL, encrypted_token_id TEXT DEFAULT NULL, encrypted_token_secret TEXT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE TABLE task_book_stack_link (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bookstack_id INT NOT NULL, bookstack_type VARCHAR(10) NOT NULL, title VARCHAR(255) NOT NULL, url VARCHAR(500) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, task_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('COMMENT ON COLUMN task_book_stack_link.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE INDEX IDX_E3E40EBB8DB60186 ON task_book_stack_link (task_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_book_stack_link (task_id, bookstack_id, bookstack_type)');
|
||||
$this->addSql('ALTER TABLE task_book_stack_link ADD CONSTRAINT FK_E3E40EBB8DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE task_book_stack_link DROP CONSTRAINT FK_E3E40EBB8DB60186');
|
||||
$this->addSql('DROP TABLE book_stack_configuration');
|
||||
$this->addSql('DROP TABLE task_book_stack_link');
|
||||
$this->addSql('ALTER TABLE project DROP bookstack_shelf_id');
|
||||
$this->addSql('ALTER TABLE project DROP bookstack_shelf_name');
|
||||
}
|
||||
}
|
||||
71
src/ApiResource/BookStackLink.php
Normal file
71
src/ApiResource/BookStackLink.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskBookStackLink;
|
||||
use App\State\BookStackLinkProcessor;
|
||||
use App\State\BookStackLinkProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/tasks/{taskId}/bookstack/links',
|
||||
uriVariables: [
|
||||
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
|
||||
],
|
||||
normalizationContext: ['groups' => ['bookstack_link:read']],
|
||||
provider: BookStackLinkProvider::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/tasks/{taskId}/bookstack/links',
|
||||
uriVariables: [
|
||||
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
|
||||
],
|
||||
denormalizationContext: ['groups' => ['bookstack_link:write']],
|
||||
normalizationContext: ['groups' => ['bookstack_link:read']],
|
||||
provider: BookStackLinkProvider::class,
|
||||
processor: BookStackLinkProcessor::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/tasks/{taskId}/bookstack/links/{id}',
|
||||
uriVariables: [
|
||||
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
|
||||
'id' => new Link(fromClass: TaskBookStackLink::class, identifiers: ['id']),
|
||||
],
|
||||
provider: BookStackLinkProvider::class,
|
||||
processor: BookStackLinkProcessor::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BookStackLink
|
||||
{
|
||||
#[Groups(['bookstack_link:read'])]
|
||||
public ?int $id = null;
|
||||
|
||||
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
|
||||
public int $bookstackId = 0;
|
||||
|
||||
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
|
||||
public string $bookstackType = '';
|
||||
|
||||
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
|
||||
public string $title = '';
|
||||
|
||||
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
|
||||
public string $url = '';
|
||||
|
||||
#[Groups(['bookstack_link:read'])]
|
||||
public ?string $createdAt = null;
|
||||
}
|
||||
40
src/ApiResource/BookStackSearchResult.php
Normal file
40
src/ApiResource/BookStackSearchResult.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use App\Entity\Task;
|
||||
use App\State\BookStackSearchResultProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/tasks/{taskId}/bookstack/search',
|
||||
uriVariables: [
|
||||
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
|
||||
],
|
||||
normalizationContext: ['groups' => ['bookstack_search:read']],
|
||||
provider: BookStackSearchResultProvider::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BookStackSearchResult
|
||||
{
|
||||
#[Groups(['bookstack_search:read'])]
|
||||
public int $id = 0;
|
||||
|
||||
#[Groups(['bookstack_search:read'])]
|
||||
public string $type = '';
|
||||
|
||||
#[Groups(['bookstack_search:read'])]
|
||||
public string $name = '';
|
||||
|
||||
#[Groups(['bookstack_search:read'])]
|
||||
public string $url = '';
|
||||
}
|
||||
45
src/ApiResource/BookStackSettings.php
Normal file
45
src/ApiResource/BookStackSettings.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\State\BookStackSettingsProcessor;
|
||||
use App\State\BookStackSettingsProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/settings/bookstack',
|
||||
normalizationContext: ['groups' => ['bookstack_settings:read']],
|
||||
provider: BookStackSettingsProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/settings/bookstack',
|
||||
denormalizationContext: ['groups' => ['bookstack_settings:write']],
|
||||
normalizationContext: ['groups' => ['bookstack_settings:read']],
|
||||
provider: BookStackSettingsProvider::class,
|
||||
processor: BookStackSettingsProcessor::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BookStackSettings
|
||||
{
|
||||
#[Groups(['bookstack_settings:read', 'bookstack_settings:write'])]
|
||||
public ?string $url = null;
|
||||
|
||||
#[Groups(['bookstack_settings:write'])]
|
||||
public ?string $tokenId = null;
|
||||
|
||||
#[Groups(['bookstack_settings:write'])]
|
||||
public ?string $tokenSecret = null;
|
||||
|
||||
#[Groups(['bookstack_settings:read'])]
|
||||
public bool $hasToken = false;
|
||||
}
|
||||
29
src/ApiResource/BookStackShelf.php
Normal file
29
src/ApiResource/BookStackShelf.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\State\BookStackShelfProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/bookstack/shelves',
|
||||
normalizationContext: ['groups' => ['bookstack_shelf:read']],
|
||||
provider: BookStackShelfProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BookStackShelf
|
||||
{
|
||||
#[Groups(['bookstack_shelf:read'])]
|
||||
public int $id = 0;
|
||||
|
||||
#[Groups(['bookstack_shelf:read'])]
|
||||
public string $name = '';
|
||||
}
|
||||
28
src/ApiResource/BookStackTestConnection.php
Normal file
28
src/ApiResource/BookStackTestConnection.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\BookStackTestConnectionProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/settings/bookstack/test',
|
||||
input: false,
|
||||
normalizationContext: ['groups' => ['bookstack_test:read']],
|
||||
provider: BookStackTestConnectionProvider::class,
|
||||
processor: BookStackTestConnectionProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class BookStackTestConnection
|
||||
{
|
||||
#[Groups(['bookstack_test:read'])]
|
||||
public bool $success = false;
|
||||
}
|
||||
52
src/Controller/TaskDocumentDownloadController.php
Normal file
52
src/Controller/TaskDocumentDownloadController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\TaskDocument;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class TaskDocumentDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id): BinaryFileResponse
|
||||
{
|
||||
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
|
||||
|
||||
if (null === $document) {
|
||||
throw new NotFoundHttpException('Document not found.');
|
||||
}
|
||||
|
||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
throw new NotFoundHttpException('File not found on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
|
||||
|
||||
// Inline for images and PDFs, attachment for everything else
|
||||
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response->setContentDisposition($disposition, $document->getOriginalName());
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ class AppFixtures extends Fixture
|
||||
$tagCalendar->setColor('#222783');
|
||||
$manager->persist($tagCalendar);
|
||||
|
||||
// Task Groups
|
||||
// Task Groups — SIRH
|
||||
$groupFrontend = new TaskGroup();
|
||||
$groupFrontend->setTitle('Frontend');
|
||||
$groupFrontend->setColor('#4A90D9');
|
||||
@@ -190,7 +190,48 @@ class AppFixtures extends Fixture
|
||||
$groupBackend->setProject($projectSirh);
|
||||
$manager->persist($groupBackend);
|
||||
|
||||
// Tasks
|
||||
// Task Groups — CRM
|
||||
$groupCrmUi = new TaskGroup();
|
||||
$groupCrmUi->setTitle('Interface');
|
||||
$groupCrmUi->setColor('#E91E63');
|
||||
$groupCrmUi->setProject($projectCrm);
|
||||
$manager->persist($groupCrmUi);
|
||||
|
||||
$groupCrmApi = new TaskGroup();
|
||||
$groupCrmApi->setTitle('API');
|
||||
$groupCrmApi->setColor('#9C27B0');
|
||||
$groupCrmApi->setProject($projectCrm);
|
||||
$manager->persist($groupCrmApi);
|
||||
|
||||
// Task Groups — ERP
|
||||
$groupErpStock = new TaskGroup();
|
||||
$groupErpStock->setTitle('Stocks');
|
||||
$groupErpStock->setColor('#4A90D9');
|
||||
$groupErpStock->setProject($projectErp);
|
||||
$manager->persist($groupErpStock);
|
||||
|
||||
$groupErpFacturation = new TaskGroup();
|
||||
$groupErpFacturation->setTitle('Facturation');
|
||||
$groupErpFacturation->setColor('#FF8F00');
|
||||
$groupErpFacturation->setProject($projectErp);
|
||||
$manager->persist($groupErpFacturation);
|
||||
|
||||
// Task Groups — Site vitrine
|
||||
$groupSiteDesign = new TaskGroup();
|
||||
$groupSiteDesign->setTitle('Design');
|
||||
$groupSiteDesign->setColor('#26A69A');
|
||||
$groupSiteDesign->setProject($projectInterne);
|
||||
$manager->persist($groupSiteDesign);
|
||||
|
||||
$groupSiteContenu = new TaskGroup();
|
||||
$groupSiteContenu->setTitle('Contenu');
|
||||
$groupSiteContenu->setColor('#795548');
|
||||
$groupSiteContenu->setProject($projectInterne);
|
||||
$manager->persist($groupSiteContenu);
|
||||
|
||||
// =============================================
|
||||
// Tasks — SIRH
|
||||
// =============================================
|
||||
$task1 = new Task();
|
||||
$task1->setNumber(1);
|
||||
$task1->setTitle('Création d\'une page de login');
|
||||
@@ -260,8 +301,199 @@ class AppFixtures extends Fixture
|
||||
$task6->addTag($tagAuth);
|
||||
$manager->persist($task6);
|
||||
|
||||
// --- Time Entries (SIRH project, admin user) ---
|
||||
// =============================================
|
||||
// Tasks — CRM
|
||||
// =============================================
|
||||
$taskCrm1 = new Task();
|
||||
$taskCrm1->setNumber(1);
|
||||
$taskCrm1->setTitle('Liste des contacts');
|
||||
$taskCrm1->setStatus($statusDone);
|
||||
$taskCrm1->setEffort($effortL);
|
||||
$taskCrm1->setPriority($priorityHigh);
|
||||
$taskCrm1->setAssignee($admin);
|
||||
$taskCrm1->setGroup($groupCrmUi);
|
||||
$taskCrm1->setProject($projectCrm);
|
||||
$manager->persist($taskCrm1);
|
||||
|
||||
$taskCrm2 = new Task();
|
||||
$taskCrm2->setNumber(2);
|
||||
$taskCrm2->setTitle('Fiche contact détaillée');
|
||||
$taskCrm2->setStatus($statusInProgress);
|
||||
$taskCrm2->setEffort($effortM);
|
||||
$taskCrm2->setPriority($priorityMedium);
|
||||
$taskCrm2->setAssignee($admin);
|
||||
$taskCrm2->setGroup($groupCrmUi);
|
||||
$taskCrm2->setProject($projectCrm);
|
||||
$manager->persist($taskCrm2);
|
||||
|
||||
$taskCrm3 = new Task();
|
||||
$taskCrm3->setNumber(3);
|
||||
$taskCrm3->setTitle('Import CSV contacts');
|
||||
$taskCrm3->setStatus($statusTodo);
|
||||
$taskCrm3->setEffort($effortXL);
|
||||
$taskCrm3->setPriority($priorityLow);
|
||||
$taskCrm3->setAssignee($admin);
|
||||
$taskCrm3->setGroup($groupCrmApi);
|
||||
$taskCrm3->setProject($projectCrm);
|
||||
$manager->persist($taskCrm3);
|
||||
|
||||
$taskCrm4 = new Task();
|
||||
$taskCrm4->setNumber(4);
|
||||
$taskCrm4->setTitle('Pipeline de vente');
|
||||
$taskCrm4->setStatus($statusInProgress);
|
||||
$taskCrm4->setEffort($effortXXL);
|
||||
$taskCrm4->setPriority($priorityHigh);
|
||||
$taskCrm4->setAssignee($admin);
|
||||
$taskCrm4->setGroup($groupCrmUi);
|
||||
$taskCrm4->setProject($projectCrm);
|
||||
$taskCrm4->addTag($tagCalendar);
|
||||
$manager->persist($taskCrm4);
|
||||
|
||||
$taskCrm5 = new Task();
|
||||
$taskCrm5->setNumber(5);
|
||||
$taskCrm5->setTitle('API recherche contacts');
|
||||
$taskCrm5->setStatus($statusReview);
|
||||
$taskCrm5->setEffort($effortM);
|
||||
$taskCrm5->setPriority($priorityMedium);
|
||||
$taskCrm5->setAssignee($admin);
|
||||
$taskCrm5->setGroup($groupCrmApi);
|
||||
$taskCrm5->setProject($projectCrm);
|
||||
$manager->persist($taskCrm5);
|
||||
|
||||
// =============================================
|
||||
// Tasks — ERP
|
||||
// =============================================
|
||||
$taskErp1 = new Task();
|
||||
$taskErp1->setNumber(1);
|
||||
$taskErp1->setTitle('Tableau de bord stocks');
|
||||
$taskErp1->setStatus($statusDone);
|
||||
$taskErp1->setEffort($effortL);
|
||||
$taskErp1->setPriority($priorityHigh);
|
||||
$taskErp1->setAssignee($admin);
|
||||
$taskErp1->setGroup($groupErpStock);
|
||||
$taskErp1->setProject($projectErp);
|
||||
$manager->persist($taskErp1);
|
||||
|
||||
$taskErp2 = new Task();
|
||||
$taskErp2->setNumber(2);
|
||||
$taskErp2->setTitle('Alertes stock bas');
|
||||
$taskErp2->setStatus($statusInProgress);
|
||||
$taskErp2->setEffort($effortM);
|
||||
$taskErp2->setPriority($priorityHigh);
|
||||
$taskErp2->setAssignee($admin);
|
||||
$taskErp2->setGroup($groupErpStock);
|
||||
$taskErp2->setProject($projectErp);
|
||||
$manager->persist($taskErp2);
|
||||
|
||||
$taskErp3 = new Task();
|
||||
$taskErp3->setNumber(3);
|
||||
$taskErp3->setTitle('Génération factures PDF');
|
||||
$taskErp3->setStatus($statusTodo);
|
||||
$taskErp3->setEffort($effortXXL);
|
||||
$taskErp3->setPriority($priorityMedium);
|
||||
$taskErp3->setAssignee($admin);
|
||||
$taskErp3->setGroup($groupErpFacturation);
|
||||
$taskErp3->setProject($projectErp);
|
||||
$manager->persist($taskErp3);
|
||||
|
||||
$taskErp4 = new Task();
|
||||
$taskErp4->setNumber(4);
|
||||
$taskErp4->setTitle('Historique mouvements stock');
|
||||
$taskErp4->setStatus($statusReview);
|
||||
$taskErp4->setEffort($effortL);
|
||||
$taskErp4->setPriority($priorityLow);
|
||||
$taskErp4->setAssignee($admin);
|
||||
$taskErp4->setGroup($groupErpStock);
|
||||
$taskErp4->setProject($projectErp);
|
||||
$manager->persist($taskErp4);
|
||||
|
||||
$taskErp5 = new Task();
|
||||
$taskErp5->setNumber(5);
|
||||
$taskErp5->setTitle('Export comptable');
|
||||
$taskErp5->setStatus($statusBlocked);
|
||||
$taskErp5->setEffort($effortXL);
|
||||
$taskErp5->setPriority($priorityHigh);
|
||||
$taskErp5->setAssignee($admin);
|
||||
$taskErp5->setGroup($groupErpFacturation);
|
||||
$taskErp5->setProject($projectErp);
|
||||
$manager->persist($taskErp5);
|
||||
|
||||
$taskErp6 = new Task();
|
||||
$taskErp6->setNumber(6);
|
||||
$taskErp6->setTitle('Inventaire annuel');
|
||||
$taskErp6->setStatus($statusTodo);
|
||||
$taskErp6->setEffort($effortS);
|
||||
$taskErp6->setPriority($priorityLow);
|
||||
$taskErp6->setAssignee($admin);
|
||||
$taskErp6->setGroup($groupErpStock);
|
||||
$taskErp6->setProject($projectErp);
|
||||
$taskErp6->addTag($tagCalendar);
|
||||
$manager->persist($taskErp6);
|
||||
|
||||
// =============================================
|
||||
// Tasks — Site vitrine
|
||||
// =============================================
|
||||
$taskSite1 = new Task();
|
||||
$taskSite1->setNumber(1);
|
||||
$taskSite1->setTitle('Maquette page d\'accueil');
|
||||
$taskSite1->setStatus($statusDone);
|
||||
$taskSite1->setEffort($effortM);
|
||||
$taskSite1->setPriority($priorityHigh);
|
||||
$taskSite1->setAssignee($admin);
|
||||
$taskSite1->setGroup($groupSiteDesign);
|
||||
$taskSite1->setProject($projectInterne);
|
||||
$manager->persist($taskSite1);
|
||||
|
||||
$taskSite2 = new Task();
|
||||
$taskSite2->setNumber(2);
|
||||
$taskSite2->setTitle('Intégration responsive');
|
||||
$taskSite2->setStatus($statusInProgress);
|
||||
$taskSite2->setEffort($effortL);
|
||||
$taskSite2->setPriority($priorityMedium);
|
||||
$taskSite2->setAssignee($admin);
|
||||
$taskSite2->setGroup($groupSiteDesign);
|
||||
$taskSite2->setProject($projectInterne);
|
||||
$manager->persist($taskSite2);
|
||||
|
||||
$taskSite3 = new Task();
|
||||
$taskSite3->setNumber(3);
|
||||
$taskSite3->setTitle('Rédaction page "À propos"');
|
||||
$taskSite3->setStatus($statusTodo);
|
||||
$taskSite3->setEffort($effortS);
|
||||
$taskSite3->setPriority($priorityLow);
|
||||
$taskSite3->setAssignee($admin);
|
||||
$taskSite3->setGroup($groupSiteContenu);
|
||||
$taskSite3->setProject($projectInterne);
|
||||
$manager->persist($taskSite3);
|
||||
|
||||
$taskSite4 = new Task();
|
||||
$taskSite4->setNumber(4);
|
||||
$taskSite4->setTitle('Formulaire de contact');
|
||||
$taskSite4->setStatus($statusReview);
|
||||
$taskSite4->setEffort($effortM);
|
||||
$taskSite4->setPriority($priorityMedium);
|
||||
$taskSite4->setAssignee($admin);
|
||||
$taskSite4->setGroup($groupSiteDesign);
|
||||
$taskSite4->setProject($projectInterne);
|
||||
$taskSite4->addTag($tagAuth);
|
||||
$manager->persist($taskSite4);
|
||||
|
||||
$taskSite5 = new Task();
|
||||
$taskSite5->setNumber(5);
|
||||
$taskSite5->setTitle('SEO et métadonnées');
|
||||
$taskSite5->setStatus($statusTodo);
|
||||
$taskSite5->setEffort($effortS);
|
||||
$taskSite5->setPriority($priorityHigh);
|
||||
$taskSite5->setAssignee($admin);
|
||||
$taskSite5->setGroup($groupSiteContenu);
|
||||
$taskSite5->setProject($projectInterne);
|
||||
$manager->persist($taskSite5);
|
||||
|
||||
// =============================================
|
||||
// Time Entries — tous les projets
|
||||
// =============================================
|
||||
$timeEntryData = [
|
||||
// SIRH — lundi à vendredi
|
||||
['title' => 'Réunion', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:00', 'stop' => '09:45', 'day' => 1],
|
||||
['title' => 'Page accueil', 'project' => $projectSirh, 'tag' => $tagPassword, 'start' => '10:00', 'stop' => '12:00', 'day' => 0],
|
||||
['title' => 'Design admin', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:30', 'stop' => '11:00', 'day' => 2],
|
||||
@@ -272,6 +504,27 @@ class AppFixtures extends Fixture
|
||||
['title' => 'Script backup BDD', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '13:30', 'stop' => '15:00', 'day' => 3],
|
||||
['title' => 'Maquette', 'project' => $projectSirh, 'tag' => null, 'start' => '09:00', 'stop' => '11:00', 'day' => 4],
|
||||
['title' => 'PC compta', 'project' => $projectSirh, 'tag' => null, 'start' => '13:30', 'stop' => '15:30', 'day' => 4],
|
||||
// CRM — lundi à vendredi
|
||||
['title' => 'Liste contacts UI', 'project' => $projectCrm, 'tag' => null, 'start' => '08:30', 'stop' => '10:00', 'day' => 0],
|
||||
['title' => 'Fiche contact', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 0],
|
||||
['title' => 'Pipeline vente', 'project' => $projectCrm, 'tag' => $tagCalendar, 'start' => '08:30', 'stop' => '09:30', 'day' => 1],
|
||||
['title' => 'Import CSV', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:30', 'day' => 2],
|
||||
['title' => 'API recherche', 'project' => $projectCrm, 'tag' => null, 'start' => '08:30', 'stop' => '10:00', 'day' => 3],
|
||||
['title' => 'Tests unitaires CRM', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 3],
|
||||
['title' => 'Revue pipeline', 'project' => $projectCrm, 'tag' => $tagCalendar, 'start' => '08:00', 'stop' => '09:00', 'day' => 4],
|
||||
// ERP — lundi à vendredi
|
||||
['title' => 'Dashboard stocks', 'project' => $projectErp, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 1],
|
||||
['title' => 'Alertes stock bas', 'project' => $projectErp, 'tag' => null, 'start' => '11:30', 'stop' => '12:30', 'day' => 2],
|
||||
['title' => 'Factures PDF', 'project' => $projectErp, 'tag' => null, 'start' => '15:30', 'stop' => '17:30', 'day' => 4],
|
||||
['title' => 'Mouvement stock', 'project' => $projectErp, 'tag' => null, 'start' => '09:00', 'stop' => '10:30', 'day' => 0],
|
||||
['title' => 'Export comptable', 'project' => $projectErp, 'tag' => $tagCalendar, 'start' => '13:00', 'stop' => '14:30', 'day' => 2],
|
||||
['title' => 'Inventaire', 'project' => $projectErp, 'tag' => null, 'start' => '13:30', 'stop' => '15:00', 'day' => 4],
|
||||
// Site vitrine — lundi à jeudi
|
||||
['title' => 'Maquette accueil', 'project' => $projectInterne, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 0],
|
||||
['title' => 'Responsive mobile', 'project' => $projectInterne, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 2],
|
||||
['title' => 'Rédaction contenu', 'project' => $projectInterne, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 1],
|
||||
['title' => 'Formulaire contact', 'project' => $projectInterne, 'tag' => $tagAuth, 'start' => '16:00', 'stop' => '17:30', 'day' => 3],
|
||||
['title' => 'SEO meta tags', 'project' => $projectInterne, 'tag' => null, 'start' => '11:00', 'stop' => '12:00', 'day' => 4],
|
||||
];
|
||||
|
||||
$monday = new DateTimeImmutable('monday this week', new DateTimeZone('UTC'));
|
||||
|
||||
72
src/Entity/BookStackConfiguration.php
Normal file
72
src/Entity/BookStackConfiguration.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
|
||||
class BookStackConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedTokenId = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedTokenSecret = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(?string $url): static
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedTokenId(): ?string
|
||||
{
|
||||
return $this->encryptedTokenId;
|
||||
}
|
||||
|
||||
public function setEncryptedTokenId(?string $encryptedTokenId): static
|
||||
{
|
||||
$this->encryptedTokenId = $encryptedTokenId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedTokenSecret(): ?string
|
||||
{
|
||||
return $this->encryptedTokenSecret;
|
||||
}
|
||||
|
||||
public function setEncryptedTokenSecret(?string $encryptedTokenSecret): static
|
||||
{
|
||||
$this->encryptedTokenSecret = $encryptedTokenSecret;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasToken(): bool
|
||||
{
|
||||
return null !== $this->encryptedTokenId && null !== $this->encryptedTokenSecret;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -31,6 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
denormalizationContext: ['groups' => ['project:write']],
|
||||
order: ['name' => 'ASC'],
|
||||
)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
|
||||
class Project
|
||||
@@ -48,7 +51,7 @@ class Project
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
@@ -56,7 +59,7 @@ class Project
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
|
||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
|
||||
private ?string $color = '#222783';
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')]
|
||||
@@ -72,6 +75,18 @@ class Project
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?string $giteaRepo = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?int $bookstackShelfId = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?string $bookstackShelfName = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private bool $archived = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -165,4 +180,40 @@ class Project
|
||||
{
|
||||
return null !== $this->giteaOwner && null !== $this->giteaRepo;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived;
|
||||
}
|
||||
|
||||
public function setArchived(bool $archived): static
|
||||
{
|
||||
$this->archived = $archived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBookstackShelfId(): ?int
|
||||
{
|
||||
return $this->bookstackShelfId;
|
||||
}
|
||||
|
||||
public function setBookstackShelfId(?int $bookstackShelfId): static
|
||||
{
|
||||
$this->bookstackShelfId = $bookstackShelfId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBookstackShelfName(): ?string
|
||||
{
|
||||
return $this->bookstackShelfName;
|
||||
}
|
||||
|
||||
public function setBookstackShelfName(?string $bookstackShelfName): static
|
||||
{
|
||||
$this->bookstackShelfName = $bookstackShelfName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,15 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private bool $archived = false;
|
||||
|
||||
/** @var Collection<int, TaskDocument> */
|
||||
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
|
||||
#[Groups(['task:read'])]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -250,4 +256,10 @@ class Task
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TaskDocument> */
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
}
|
||||
|
||||
113
src/Entity/TaskBookStackLink.php
Normal file
113
src/Entity/TaskBookStackLink.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TaskBookStackLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
|
||||
class TaskBookStackLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Task $task;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $bookstackId;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $bookstackType;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(length: 500)]
|
||||
private string $url;
|
||||
|
||||
#[ORM\Column]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTask(): Task
|
||||
{
|
||||
return $this->task;
|
||||
}
|
||||
|
||||
public function setTask(Task $task): static
|
||||
{
|
||||
$this->task = $task;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBookstackId(): int
|
||||
{
|
||||
return $this->bookstackId;
|
||||
}
|
||||
|
||||
public function setBookstackId(int $bookstackId): static
|
||||
{
|
||||
$this->bookstackId = $bookstackId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBookstackType(): string
|
||||
{
|
||||
return $this->bookstackType;
|
||||
}
|
||||
|
||||
public function setBookstackType(string $bookstackType): static
|
||||
{
|
||||
$this->bookstackType = $bookstackType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(string $url): static
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
164
src/Entity/TaskDocument.php
Normal file
164
src/Entity/TaskDocument.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\EventListener\TaskDocumentListener;
|
||||
use App\State\TaskDocumentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false),
|
||||
new Get(),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: TaskDocumentProcessor::class,
|
||||
deserialize: false,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_document:read']],
|
||||
denormalizationContext: ['groups' => ['task_document:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\EntityListeners([TaskDocumentListener::class])]
|
||||
class TaskDocument
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['task_document:read', 'task_document:write'])]
|
||||
private ?Task $task = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $originalName = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $fileName = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $mimeType = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?int $size = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?User $uploadedBy = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTask(): ?Task
|
||||
{
|
||||
return $this->task;
|
||||
}
|
||||
|
||||
public function setTask(?Task $task): static
|
||||
{
|
||||
$this->task = $task;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOriginalName(): ?string
|
||||
{
|
||||
return $this->originalName;
|
||||
}
|
||||
|
||||
public function setOriginalName(string $originalName): static
|
||||
{
|
||||
$this->originalName = $originalName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFileName(): ?string
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
public function setFileName(string $fileName): static
|
||||
{
|
||||
$this->fileName = $fileName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMimeType(): ?string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function setMimeType(string $mimeType): static
|
||||
{
|
||||
$this->mimeType = $mimeType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSize(): ?int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function setSize(int $size): static
|
||||
{
|
||||
$this->size = $size;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUploadedBy(): ?User
|
||||
{
|
||||
return $this->uploadedBy;
|
||||
}
|
||||
|
||||
public function setUploadedBy(?User $uploadedBy): static
|
||||
{
|
||||
$this->uploadedBy = $uploadedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
30
src/EventListener/TaskDocumentListener.php
Normal file
30
src/EventListener/TaskDocumentListener.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\TaskDocument;
|
||||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class TaskDocumentListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $uploadDir,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void
|
||||
{
|
||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
if (!unlink($filePath)) {
|
||||
$this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]);
|
||||
}
|
||||
} else {
|
||||
$this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Exception/BookStackApiException.php
Normal file
16
src/Exception/BookStackApiException.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class BookStackApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
22
src/Repository/BookStackConfigurationRepository.php
Normal file
22
src/Repository/BookStackConfigurationRepository.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BookStackConfiguration;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class BookStackConfigurationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BookStackConfiguration::class);
|
||||
}
|
||||
|
||||
public function findSingleton(): ?BookStackConfiguration
|
||||
{
|
||||
return $this->findOneBy([]);
|
||||
}
|
||||
}
|
||||
23
src/Repository/TaskBookStackLinkRepository.php
Normal file
23
src/Repository/TaskBookStackLinkRepository.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TaskBookStackLink;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class TaskBookStackLinkRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskBookStackLink::class);
|
||||
}
|
||||
|
||||
/** @return TaskBookStackLink[] */
|
||||
public function findByTaskId(int $taskId): array
|
||||
{
|
||||
return $this->findBy(['task' => $taskId], ['createdAt' => 'DESC']);
|
||||
}
|
||||
}
|
||||
235
src/Service/BookStackApiService.php
Normal file
235
src/Service/BookStackApiService.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\BookStackConfiguration;
|
||||
use App\Exception\BookStackApiException;
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
final class BookStackApiService
|
||||
{
|
||||
/** @var array<int, int[]> */
|
||||
private array $shelfBookCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly BookStackConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
) {}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
try {
|
||||
$this->request('GET', '/api/docs.json');
|
||||
|
||||
return true;
|
||||
} catch (BookStackApiException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{id: int, name: string}>
|
||||
*/
|
||||
public function listShelves(): array
|
||||
{
|
||||
$result = [];
|
||||
$offset = 0;
|
||||
$count = 100;
|
||||
|
||||
do {
|
||||
$data = $this->request('GET', '/api/shelves', [
|
||||
'query' => ['count' => $count, 'offset' => $offset],
|
||||
]);
|
||||
$items = $data['data'] ?? [];
|
||||
$result = array_merge($result, $items);
|
||||
$offset += $count;
|
||||
} while (!empty($items) && $count === count($items));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for pages and books within a specific shelf.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Fetch the shelf's book IDs
|
||||
* 2. Run two search queries (one for pages, one for books)
|
||||
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
|
||||
*
|
||||
* @return array<array{id: int, type: string, name: string, url: string}>
|
||||
*/
|
||||
public function searchInShelf(int $shelfId, string $query): array
|
||||
{
|
||||
$bookIds = $this->getShelfBookIds($shelfId);
|
||||
|
||||
if (empty($bookIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = $this->getConfiguration();
|
||||
$baseUrl = rtrim($config->getUrl() ?? '', '/');
|
||||
$trimmed = trim($query);
|
||||
|
||||
// BookStack search API accepts {type:X} for one type at a time — run two queries
|
||||
$pageResults = $this->request('GET', '/api/search', [
|
||||
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
|
||||
]);
|
||||
$bookResults = $this->request('GET', '/api/search', [
|
||||
'query' => ['query' => $trimmed.' {type:book}', 'count' => 50],
|
||||
]);
|
||||
|
||||
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
|
||||
|
||||
// Build a map of bookId → bookSlug for URL construction
|
||||
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
|
||||
$bookSlugs = [];
|
||||
foreach ($shelfData['books'] ?? [] as $book) {
|
||||
$bookSlugs[$book['id']] = $book['slug'] ?? '';
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($allResults as $item) {
|
||||
$type = $item['type'] ?? '';
|
||||
|
||||
if ('page' === $type) {
|
||||
$bookId = $item['book_id'] ?? 0;
|
||||
if (in_array($bookId, $bookIds, true)) {
|
||||
$bookSlug = $bookSlugs[$bookId] ?? '';
|
||||
$filtered[] = [
|
||||
'id' => $item['id'],
|
||||
'type' => 'page',
|
||||
'name' => $item['name'] ?? '',
|
||||
'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'],
|
||||
];
|
||||
}
|
||||
} elseif ('book' === $type) {
|
||||
if (in_array($item['id'], $bookIds, true)) {
|
||||
$filtered[] = [
|
||||
'id' => $item['id'],
|
||||
'type' => 'book',
|
||||
'name' => $item['name'] ?? '',
|
||||
'url' => $baseUrl.'/books/'.$item['slug'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}
|
||||
*/
|
||||
public function getPage(int $id): array
|
||||
{
|
||||
return $this->request('GET', sprintf('/api/pages/%d', $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}
|
||||
*/
|
||||
public function getBook(int $id): array
|
||||
{
|
||||
return $this->request('GET', sprintf('/api/books/%d', $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getShelfBookIds(int $shelfId): array
|
||||
{
|
||||
if (isset($this->shelfBookCache[$shelfId])) {
|
||||
return $this->shelfBookCache[$shelfId];
|
||||
}
|
||||
|
||||
$data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
|
||||
$books = $data['books'] ?? [];
|
||||
|
||||
$ids = array_map(static fn (array $book): int => $book['id'], $books);
|
||||
$this->shelfBookCache[$shelfId] = $ids;
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function getConfiguration(): BookStackConfiguration
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
if (null === $config) {
|
||||
throw new BookStackApiException('BookStack is not configured.');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tokenId: string, tokenSecret: string}
|
||||
*/
|
||||
private function getDecryptedTokens(BookStackConfiguration $config): array
|
||||
{
|
||||
$encryptedId = $config->getEncryptedTokenId();
|
||||
$encryptedSecret = $config->getEncryptedTokenSecret();
|
||||
|
||||
if (null === $encryptedId || null === $encryptedSecret) {
|
||||
throw new BookStackApiException('BookStack tokens are not set.');
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
'tokenId' => $this->tokenEncryptor->decrypt($encryptedId),
|
||||
'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
throw new BookStackApiException('Failed to decrypt BookStack tokens: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractError(HttpExceptionInterface $e): string
|
||||
{
|
||||
try {
|
||||
$body = $e->getResponse()->getContent(false);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
return $data['message'] ?? $data['error'] ?? $body;
|
||||
}
|
||||
|
||||
return $body ?: 'Unknown BookStack error';
|
||||
} catch (ExceptionInterface) {
|
||||
return 'BookStack API error (HTTP '.$e->getResponse()->getStatusCode().')';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function request(string $method, string $path, array $options = []): array
|
||||
{
|
||||
$config = $this->getConfiguration();
|
||||
$tokens = $this->getDecryptedTokens($config);
|
||||
|
||||
$options['headers'] = array_merge($options['headers'] ?? [], [
|
||||
'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']),
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
$options['timeout'] = 10;
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
$message = $this->extractError($e);
|
||||
|
||||
throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e);
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new BookStackApiException('BookStack API error: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ use App\Repository\GiteaConfigurationRepository;
|
||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class GiteaApiService
|
||||
{
|
||||
@@ -132,17 +134,21 @@ final readonly class GiteaApiService
|
||||
/**
|
||||
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
|
||||
*/
|
||||
public function listCommits(Project $project, string $branch): array
|
||||
public function listBranchCommits(Project $project, string $branch): array
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
|
||||
return $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/commits',
|
||||
$defaultBranch = $this->getDefaultBranch($project);
|
||||
|
||||
$data = $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/compare/%s...%s',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
), [
|
||||
'query' => ['sha' => $branch, 'limit' => 30],
|
||||
]);
|
||||
$defaultBranch,
|
||||
urlencode($branch),
|
||||
));
|
||||
|
||||
return $data['commits'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,7 +209,27 @@ final readonly class GiteaApiService
|
||||
throw new GiteaApiException('Gitea token is not set.');
|
||||
}
|
||||
|
||||
return $this->tokenEncryptor->decrypt($encrypted);
|
||||
try {
|
||||
return $this->tokenEncryptor->decrypt($encrypted);
|
||||
} catch (Throwable $e) {
|
||||
throw new GiteaApiException('Failed to decrypt Gitea token: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractGiteaError(HttpExceptionInterface $e): string
|
||||
{
|
||||
try {
|
||||
$body = $e->getResponse()->getContent(false);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
return $data['message'] ?? $data['error'] ?? $body;
|
||||
}
|
||||
|
||||
return $body ?: 'Unknown Gitea error';
|
||||
} catch (ExceptionInterface) {
|
||||
return 'Gitea API error (HTTP '.$e->getResponse()->getStatusCode().')';
|
||||
}
|
||||
}
|
||||
|
||||
private function assertProjectHasRepo(Project $project): void
|
||||
@@ -231,6 +257,10 @@ final readonly class GiteaApiService
|
||||
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
$message = $this->extractGiteaError($e);
|
||||
|
||||
throw new GiteaApiException($message, $e->getResponse()->getStatusCode(), $e);
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
@@ -4,31 +4,50 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use SodiumException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class TokenEncryptor
|
||||
final class TokenEncryptor
|
||||
{
|
||||
private string $key;
|
||||
private readonly string $key;
|
||||
private readonly bool $configured;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')]
|
||||
#[Autowire('%env(ENCRYPTION_KEY)%')]
|
||||
string $encryptionKey,
|
||||
) {
|
||||
if ('' === $encryptionKey) {
|
||||
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY environment variable must be set.');
|
||||
$this->key = '';
|
||||
$this->configured = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->key = sodium_hex2bin($encryptionKey);
|
||||
try {
|
||||
$key = sodium_hex2bin($encryptionKey);
|
||||
} catch (SodiumException) {
|
||||
$this->key = '';
|
||||
$this->configured = false;
|
||||
|
||||
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($this->key, '8bit')) {
|
||||
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY must be a valid sodium secret box key.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
|
||||
$this->key = '';
|
||||
$this->configured = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->key = $key;
|
||||
$this->configured = true;
|
||||
}
|
||||
|
||||
public function encrypt(string $plaintext): string
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
|
||||
|
||||
@@ -37,6 +56,8 @@ final readonly class TokenEncryptor
|
||||
|
||||
public function decrypt(string $encrypted): string
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$decoded = sodium_hex2bin($encrypted);
|
||||
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
|
||||
$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
|
||||
@@ -49,4 +70,11 @@ final readonly class TokenEncryptor
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (!$this->configured) {
|
||||
throw new RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
src/State/BookStackLinkProcessor.php
Normal file
79
src/State/BookStackLinkProcessor.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\BookStackLink;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskBookStackLink;
|
||||
use App\Repository\TaskBookStackLinkRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class BookStackLinkProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private TaskBookStackLinkRepository $linkRepository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?BookStackLink
|
||||
{
|
||||
if ($operation instanceof Delete) {
|
||||
return $this->handleDelete($uriVariables);
|
||||
}
|
||||
|
||||
return $this->handleCreate($data, $uriVariables);
|
||||
}
|
||||
|
||||
private function handleCreate(mixed $data, array $uriVariables): BookStackLink
|
||||
{
|
||||
assert($data instanceof BookStackLink);
|
||||
|
||||
$taskId = $uriVariables['taskId'] ?? 0;
|
||||
$task = $this->em->getRepository(Task::class)->find($taskId);
|
||||
|
||||
if (null === $task) {
|
||||
throw new NotFoundHttpException('Task not found.');
|
||||
}
|
||||
|
||||
$link = new TaskBookStackLink();
|
||||
$link->setTask($task);
|
||||
$link->setBookstackId($data->bookstackId);
|
||||
$link->setBookstackType($data->bookstackType);
|
||||
$link->setTitle($data->title);
|
||||
$link->setUrl($data->url);
|
||||
|
||||
$this->em->persist($link);
|
||||
$this->em->flush();
|
||||
|
||||
$result = new BookStackLink();
|
||||
$result->id = $link->getId();
|
||||
$result->bookstackId = $link->getBookstackId();
|
||||
$result->bookstackType = $link->getBookstackType();
|
||||
$result->title = $link->getTitle();
|
||||
$result->url = $link->getUrl();
|
||||
$result->createdAt = $link->getCreatedAt()->format('c');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function handleDelete(array $uriVariables): null
|
||||
{
|
||||
$linkId = $uriVariables['id'] ?? 0;
|
||||
$link = $this->linkRepository->find($linkId);
|
||||
|
||||
if (null === $link) {
|
||||
throw new NotFoundHttpException('Link not found.');
|
||||
}
|
||||
|
||||
$this->em->remove($link);
|
||||
$this->em->flush();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
src/State/BookStackLinkProvider.php
Normal file
54
src/State/BookStackLinkProvider.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BookStackLink;
|
||||
use App\Entity\TaskBookStackLink;
|
||||
use App\Repository\TaskBookStackLinkRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class BookStackLinkProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TaskBookStackLinkRepository $linkRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|BookStackLink
|
||||
{
|
||||
if ($operation instanceof Post) {
|
||||
return new BookStackLink();
|
||||
}
|
||||
|
||||
if ($operation instanceof Delete) {
|
||||
$link = $this->linkRepository->find($uriVariables['id'] ?? 0);
|
||||
if (null === $link) {
|
||||
throw new NotFoundHttpException('Link not found.');
|
||||
}
|
||||
$dto = new BookStackLink();
|
||||
$dto->id = $link->getId();
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$taskId = $uriVariables['taskId'] ?? 0;
|
||||
$links = $this->linkRepository->findByTaskId($taskId);
|
||||
|
||||
return array_map(static function (TaskBookStackLink $link): BookStackLink {
|
||||
$dto = new BookStackLink();
|
||||
$dto->id = $link->getId();
|
||||
$dto->bookstackId = $link->getBookstackId();
|
||||
$dto->bookstackType = $link->getBookstackType();
|
||||
$dto->title = $link->getTitle();
|
||||
$dto->url = $link->getUrl();
|
||||
$dto->createdAt = $link->getCreatedAt()->format('c');
|
||||
|
||||
return $dto;
|
||||
}, $links);
|
||||
}
|
||||
}
|
||||
62
src/State/BookStackSearchResultProvider.php
Normal file
62
src/State/BookStackSearchResultProvider.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BookStackSearchResult;
|
||||
use App\Entity\Task;
|
||||
use App\Exception\BookStackApiException;
|
||||
use App\Service\BookStackApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final readonly class BookStackSearchResultProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private BookStackApiService $bookStackApiService,
|
||||
private EntityManagerInterface $em,
|
||||
private RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$taskId = $uriVariables['taskId'] ?? 0;
|
||||
$task = $this->em->getRepository(Task::class)->find($taskId);
|
||||
|
||||
if (null === $task || null === $task->getProject()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$shelfId = $task->getProject()->getBookstackShelfId();
|
||||
if (null === $shelfId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$query = $request?->query->get('q', '') ?? '';
|
||||
|
||||
if ('' === trim($query)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->bookStackApiService->searchInShelf($shelfId, $query);
|
||||
} catch (BookStackApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return array_map(static function (array $item): BookStackSearchResult {
|
||||
$dto = new BookStackSearchResult();
|
||||
$dto->id = $item['id'];
|
||||
$dto->type = $item['type'];
|
||||
$dto->name = $item['name'];
|
||||
$dto->url = $item['url'];
|
||||
|
||||
return $dto;
|
||||
}, $results);
|
||||
}
|
||||
}
|
||||
49
src/State/BookStackSettingsProcessor.php
Normal file
49
src/State/BookStackSettingsProcessor.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\BookStackSettings;
|
||||
use App\Entity\BookStackConfiguration;
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
use App\Service\TokenEncryptor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class BookStackSettingsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private BookStackConfigurationRepository $configRepository,
|
||||
private TokenEncryptor $tokenEncryptor,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
|
||||
{
|
||||
assert($data instanceof BookStackSettings);
|
||||
|
||||
$config = $this->configRepository->findSingleton();
|
||||
if (null === $config) {
|
||||
$config = new BookStackConfiguration();
|
||||
}
|
||||
|
||||
$config->setUrl($data->url);
|
||||
|
||||
if (null !== $data->tokenId && '' !== $data->tokenId
|
||||
&& null !== $data->tokenSecret && '' !== $data->tokenSecret) {
|
||||
$config->setEncryptedTokenId($this->tokenEncryptor->encrypt($data->tokenId));
|
||||
$config->setEncryptedTokenSecret($this->tokenEncryptor->encrypt($data->tokenSecret));
|
||||
}
|
||||
|
||||
$this->em->persist($config);
|
||||
$this->em->flush();
|
||||
|
||||
$result = new BookStackSettings();
|
||||
$result->url = $config->getUrl();
|
||||
$result->hasToken = $config->hasToken();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
30
src/State/BookStackSettingsProvider.php
Normal file
30
src/State/BookStackSettingsProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BookStackSettings;
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
|
||||
final readonly class BookStackSettingsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private BookStackConfigurationRepository $configRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
$dto = new BookStackSettings();
|
||||
|
||||
if (null !== $config) {
|
||||
$dto->url = $config->getUrl();
|
||||
$dto->hasToken = $config->hasToken();
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
36
src/State/BookStackShelfProvider.php
Normal file
36
src/State/BookStackShelfProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BookStackShelf;
|
||||
use App\Exception\BookStackApiException;
|
||||
use App\Service\BookStackApiService;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final readonly class BookStackShelfProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private BookStackApiService $bookStackApiService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
try {
|
||||
$shelves = $this->bookStackApiService->listShelves();
|
||||
} catch (BookStackApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return array_map(static function (array $shelf): BookStackShelf {
|
||||
$dto = new BookStackShelf();
|
||||
$dto->id = $shelf['id'] ?? 0;
|
||||
$dto->name = $shelf['name'] ?? '';
|
||||
|
||||
return $dto;
|
||||
}, $shelves);
|
||||
}
|
||||
}
|
||||
31
src/State/BookStackTestConnectionProvider.php
Normal file
31
src/State/BookStackTestConnectionProvider.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\BookStackTestConnection;
|
||||
use App\Service\BookStackApiService;
|
||||
|
||||
final readonly class BookStackTestConnectionProvider implements ProviderInterface, ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private BookStackApiService $bookStackApiService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
|
||||
{
|
||||
return new BookStackTestConnection();
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
|
||||
{
|
||||
$result = new BookStackTestConnection();
|
||||
$result->success = $this->bookStackApiService->testConnection();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final readonly class GiteaBranchProvider implements ProviderInterface
|
||||
{
|
||||
@@ -40,8 +41,8 @@ final readonly class GiteaBranchProvider implements ProviderInterface
|
||||
|
||||
try {
|
||||
$branches = $this->giteaApiService->listBranches($project, $taskCode);
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
} catch (GiteaApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
@@ -50,7 +51,7 @@ final readonly class GiteaBranchProvider implements ProviderInterface
|
||||
$dto->name = $branch['name'];
|
||||
|
||||
try {
|
||||
$commits = $this->giteaApiService->listCommits($project, $branch['name']);
|
||||
$commits = $this->giteaApiService->listBranchCommits($project, $branch['name']);
|
||||
$dto->commits = array_map(static fn (array $c): array => [
|
||||
'sha' => substr($c['sha'] ?? '', 0, 7),
|
||||
'message' => $c['commit']['message'] ?? '',
|
||||
@@ -58,6 +59,7 @@ final readonly class GiteaBranchProvider implements ProviderInterface
|
||||
'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '',
|
||||
], $commits);
|
||||
} catch (GiteaApiException) {
|
||||
// Commits fetch failure should not block branch listing
|
||||
$dto->commits = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final readonly class GiteaPullRequestProvider implements ProviderInterface
|
||||
{
|
||||
@@ -35,8 +36,8 @@ final readonly class GiteaPullRequestProvider implements ProviderInterface
|
||||
|
||||
try {
|
||||
$prs = $this->giteaApiService->listPullRequests($project, $taskCode);
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
} catch (GiteaApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return array_map(static function (array $pr): GiteaPullRequest {
|
||||
|
||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaRepository;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final readonly class GiteaRepositoryProvider implements ProviderInterface
|
||||
{
|
||||
@@ -20,8 +21,8 @@ final readonly class GiteaRepositoryProvider implements ProviderInterface
|
||||
{
|
||||
try {
|
||||
$repos = $this->giteaApiService->listRepositories();
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
} catch (GiteaApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return array_map(static function (array $repo): GiteaRepository {
|
||||
|
||||
95
src/State/TaskDocumentProcessor.php
Normal file
95
src/State/TaskDocumentProcessor.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskDocument;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<TaskDocument, TaskDocument>
|
||||
*/
|
||||
final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
{
|
||||
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private string $uploadDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param TaskDocument $data
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (null === $request) {
|
||||
throw new BadRequestHttpException('No request available.');
|
||||
}
|
||||
|
||||
$file = $request->files->get('file');
|
||||
|
||||
if (null === $file || !$file->isValid()) {
|
||||
throw new BadRequestHttpException('No valid file uploaded.');
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
|
||||
}
|
||||
|
||||
$taskIri = $request->request->get('task');
|
||||
|
||||
if (null === $taskIri || '' === $taskIri) {
|
||||
throw new BadRequestHttpException('Task IRI is required.');
|
||||
}
|
||||
|
||||
// Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
|
||||
$taskId = (int) basename((string) $taskIri);
|
||||
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
|
||||
|
||||
if (null === $task) {
|
||||
throw new BadRequestHttpException('Task not found.');
|
||||
}
|
||||
|
||||
// Capture file metadata BEFORE move() — move invalidates the temp file
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$extension = $file->getClientOriginalExtension() ?: 'bin';
|
||||
$mimeType = $file->getClientMimeType() ?? 'application/octet-stream';
|
||||
$fileSize = $file->getSize();
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
$fileName = $uuid.'.'.$extension;
|
||||
|
||||
if (!is_dir($this->uploadDir)) {
|
||||
mkdir($this->uploadDir, 0o775, true);
|
||||
}
|
||||
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$document = new TaskDocument();
|
||||
$document->setTask($task);
|
||||
$document->setOriginalName($originalName);
|
||||
$document->setFileName($fileName);
|
||||
$document->setMimeType($mimeType);
|
||||
$document->setSize($fileSize);
|
||||
$document->setCreatedAt(new DateTimeImmutable());
|
||||
$document->setUploadedBy($this->security->getUser());
|
||||
|
||||
$this->entityManager->persist($document);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $document;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user