Compare commits

..

94 Commits

Author SHA1 Message Date
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
c72f17eb93 docs : add time tracking design spec
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:59:02 +01:00
4c19b68156 fix(gitea) : propagate API errors instead of silently returning empty results
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:58:55 +01:00
63e4af785e chore : update auto-generated reference config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:58:47 +01:00
f5e41bc377 docs : add client portal design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:54:49 +01:00
f978df6a4b fix(frontend) : explicit import for ConfirmDeleteDocumentModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:53:39 +01:00
98e832afa5 fix(frontend) : use dedicated confirm modal component for document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:10 +01:00
cbfbb16c59 feat(frontend) : replace confirm() with themed modal for document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:33:44 +01:00
354d994766 fix : tag TaskDocumentListener as doctrine entity listener
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:32:02 +01:00
06771c17e0 fix(bookstack) : add uriVariables to BookStackLink and BookStackSearchResult
API Platform 4 requires explicit uriVariables declaration for
URI template parameters on DTO resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:27:57 +01:00
9908f34580 fix(frontend) : refresh documents locally after upload/delete and improve progress UX
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:21:04 +01:00
7bf632c1da feat(bookstack) : integrate TaskBookStackLinks into TaskModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:39 +01:00
66a75c6b6a feat(bookstack) : add TaskBookStackLinks component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:37 +01:00
f53b2f3d1f feat(bookstack) : add shelf select to ProjectDrawer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:34 +01:00
c9a3c7c5f8 feat(bookstack) : add BookStack tab to admin page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:30 +01:00
5777e8386f feat(bookstack) : add AdminBookStackTab component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:27 +01:00
06f2a9e1ea feat(bookstack) : add i18n translations for BookStack
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:24 +01:00
b5fa9e7d06 feat(bookstack) : add frontend BookStack service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:20 +01:00
73ecbbc95b feat(bookstack) : add frontend BookStack DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:12 +01:00
5327155a80 fix(frontend) : add missing useTaskDocumentService imports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:12:29 +01:00
9e638c32b8 feat(bookstack) : add BookStackSearchResult API resource for shelf-scoped search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:10:47 +01:00
bc331982d5 feat(bookstack) : add BookStackLink API resource with CRUD operations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:10:24 +01:00
1e311242a9 feat(bookstack) : add BookStackShelf API resource for listing shelves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:51 +01:00
97c6ef6a52 feat(bookstack) : add BookStackTestConnection API resource
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:36 +01:00
245a8a932e feat(frontend) : integrate documents into TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:09:20 +01:00
28fbc73248 feat(bookstack) : add BookStackSettings API resource with provider and processor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:20 +01:00
df00b27a64 feat(bookstack) : add BookStackApiService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:08:51 +01:00
ee38f99022 feat(bookstack) : add BookStackApiException
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:08:15 +01:00
48ef434f8b feat(frontend) : add document upload, list and preview components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:08:10 +01:00
e53862d71f feat(frontend) : add TaskDocument DTO, service and i18n translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:07:00 +01:00
52063cb4fa feat(bookstack) : add migration for BookStack tables and Project columns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:06:14 +01:00
06832c24e1 feat : add document upload processor, download controller and cleanup listener
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:05:58 +01:00
8fbafc1f8a feat(bookstack) : add bookstackShelfId and bookstackShelfName to Project 2026-03-15 18:05:13 +01:00
585cc3368f feat(bookstack) : add TaskBookStackLink entity and repository 2026-03-15 18:05:09 +01:00
043826075d feat(bookstack) : add BookStackConfiguration entity and repository 2026-03-15 18:05:07 +01:00
8ec98a593a feat : add task_document migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:04:14 +01:00
3dd2d39222 refactor : rename GITEA_ENCRYPTION_KEY to ENCRYPTION_KEY
Generic encryption key name for shared use across Gitea and BookStack
token encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:52 +01:00
cfaa6c42ec feat : add TaskDocument entity with Task relation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:20 +01:00
a36cd92a7f feat(config) : set upload limits to 50MB and add uploads volume
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:02:09 +01:00
bfffbe7041 docs : add BookStack connector implementation plan
21-task plan covering backend (entities, migration, service, API
resources) and frontend (DTOs, service, admin tab, project drawer,
task modal integration). Reviewed and fixed: readonly class issue,
page URL construction, Delete provider handling, task:read group,
search query syntax, security attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:00:34 +01:00
c9993ef32d docs : add task documents implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:59:29 +01:00
efc3742fff docs : update task documents spec after review
Address review findings: add EntityListener for file cleanup on
cascade delete, dedicated download endpoint, sequential upload,
i18n keys, .gitignore entry, and error handling strategy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:49:18 +01:00
e047b98bed docs : update BookStack spec with review fixes
Address critical and important review findings: search-in-shelf
algorithm detail, unique constraint, TokenEncryptor refactoring,
pagination specifics, and technical notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:48:10 +01:00
758c9f6fbd docs : add task documents upload design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:46:10 +01:00
2c93e83e6b docs : add BookStack connector design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:45:30 +01:00
25b648a1b1 fix(frontend) : align time-tracking filters with view mode toggle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:34:07 +01:00
445f51b473 fix(gitea) : fetch only branch-specific commits using compare API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:16:55 +01:00
f888a29e0a refactor(frontend) : make page headers and filters sticky across all pages
Wrap title + filters in a sticky container (top-8 sm:top-12, z-20, bg-white)
on all pages for consistent scroll behavior. Also fix SidebarTimer icon
visibility when sidebar is collapsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:21:45 +01:00
b48ca10304 feat : populate all projects with tasks, groups and time entries in fixtures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:14:15 +01:00
802659434f fix(frontend) : fix time-tracking page scroll with fixed header and filters
Restructure time-tracking page layout so the page title and filters
stay fixed while only the calendar grid body scrolls internally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:10:04 +01:00
25aef9b2d5 feat : add dashboard with Chart.js charts and filters
Implement the dashboard page with real data from the API:
- KPI cards (hours, active tasks, total tasks, projects)
- Charts: hours by day (line), hours by project (doughnut),
  tasks by status (doughnut), tasks by priority (bar),
  tasks by project (horizontal stacked bar)
- Filters: period (week/month), project, user
- Add chart.js and vue-chartjs dependencies
- Add dashboard sidebar icon and translations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:05:35 +01:00
0733ac16cd feat : add project archiving feature
Allow projects to be archived/unarchived from the ProjectDrawer, with a
toggle filter on the projects page to show/hide archived projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 08:58:29 +01:00
Matthieu
c0b16ef6dc refactor(frontend) : redesign TaskGitSection with tabs and collapsible commits, add scrollable modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:44:30 +01:00
Matthieu
c89f9c5596 fix : load Gitea URL on modal open instead of onMounted
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:19:54 +01:00
Matthieu
94d7794c31 fix : add task:read group to Project gitea fields for TaskModal visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:19 +01:00
Matthieu
3c0baee661 feat : add symfony/http-client dependency for Gitea integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:06:42 +01:00
Matthieu
c7a0dafae8 feat : integrate TaskGitSection into TaskModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:04:29 +01:00
Matthieu
6eeacd2cb0 feat : add TaskGitSection component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:03:51 +01:00
Matthieu
027e31e139 feat : add Gitea repo selector to ProjectDrawer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:02:57 +01:00
Matthieu
f8c94cb177 feat : add Gitea tab to admin page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:02:00 +01:00
Matthieu
5b204a3464 feat : add AdminGiteaTab component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:01:39 +01:00
Matthieu
92baf8ac0e feat : add Gitea i18n keys
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:01:05 +01:00
Matthieu
2073339d4f feat : add gitea fields to Project DTO
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:00:35 +01:00
Matthieu
e278286146 feat : add Gitea frontend service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:00:17 +01:00
Matthieu
a6c5e54619 feat : add Gitea TypeScript DTOs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:56 +01:00
Matthieu
5135e28e3a feat : add branch name generation endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:32 +01:00
Matthieu
3d0fad3735 feat : add task Gitea pull requests endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:07 +01:00
Matthieu
dcbf5db308 feat : add task Gitea branches endpoints (list + create)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:58:41 +01:00
Matthieu
7b1aa22c15 feat : add Gitea repositories list endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:58:09 +01:00
Matthieu
5577884c13 feat : add Gitea test connection endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:50 +01:00
Matthieu
be2e7c60a3 feat : add Gitea settings API resource with provider/processor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:31 +01:00
Matthieu
136d0eaaa4 feat : add GiteaApiService with branch/commit/PR methods
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:01 +01:00
Matthieu
0b8e2bfc63 feat : add GiteaApiException
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:56:25 +01:00
Matthieu
28e943b519 feat : add TokenEncryptor service with sodium encryption
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:56:14 +01:00
Matthieu
50690e6680 feat : add migration for GiteaConfiguration and Project gitea fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:55:39 +01:00
Matthieu
c82b6d1b32 feat : add gitea owner/repo fields to Project entity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:55:19 +01:00
Matthieu
6ae014fe8a feat : add GiteaConfiguration entity with repository
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:55:00 +01:00
Matthieu
3ec9424bb2 docs : add Gitea integration implementation plan
23 tasks across 7 chunks covering:
- Backend: GiteaConfiguration entity, TokenEncryptor, GiteaApiService
- API: settings CRUD, test connection, repositories list, task branches/PRs
- Frontend: DTOs, service, admin tab, ProjectDrawer repo selector, TaskGitSection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:40:28 +01:00
Matthieu
aa5f6cc7c1 docs : address spec review feedback for Gitea integration
- Use dedicated GiteaConfiguration entity instead of generic Setting table
- Add token encryption with SodiumEncryptor + GITEA_ENCRYPTION_KEY
- Split aggregated /gitea/info into separate /branches and /pull-requests endpoints
- Fix branch pattern matching to avoid PROJ-420 matching PROJ-42
- Add error handling strategy with GiteaApiException and degraded UI state
- Add slug generation via AsciiSlugger with 50 char limit
- Add test connection endpoint
- Extract TaskGitSection.vue component
- Add frontend service layer (gitea.ts)
- Add i18n consideration
- Add "copy branch name" feature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:16:29 +01:00
Matthieu
14358fdddc docs : add Gitea integration design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:13:36 +01:00
Matthieu
3ffd18138b chore : update auto-generated config reference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:07:02 +01:00
Matthieu
e5e722c019 docs : add implementation plans for admin clients and time entry multi-type select
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:58 +01:00
Matthieu
bc9471e4ba fix(backend) : add task:read serialization group to Project id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:52 +01:00
Matthieu
cb5aa4584c feat(frontend) : add tag, assignee and status filters on project page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:48 +01:00
Matthieu
1d0f9a28c3 feat(frontend) : add kanban drag & drop and improve filter selects on my-tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:44 +01:00
Matthieu
d3ea09319c feat(frontend) : show project code and task number badge in TaskModal header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:39 +01:00
Matthieu
e85ea42d7c docs : update CLAUDE.md structure and fix spec formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:35 +01:00
Matthieu
7540c99501 feat : add my-tasks page with Kanban and List views
Add a /my-tasks page displaying all non-archived tasks across projects
with server-side filtering (assignee, project, group, priority, effort,
tags, status) and two view modes (Kanban columns by status, List view).
Includes sidebar navigation link and i18n translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:34:16 +01:00
Matthieu
c60f531607 docs : add my-tasks page implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:28:39 +01:00
Matthieu
638bb2b686 docs : address spec review feedback for my-tasks page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:23:45 +01:00
Matthieu
7b8c754987 docs : add my tasks page design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:21:34 +01:00
Matthieu
bf9faee5f4 feat(frontend) : add current time indicator line on calendar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:06:09 +01:00
Matthieu
7d1d81688e refactor(frontend) : replace TaskDrawer with TaskModal for ticket create/edit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:06:04 +01:00
Matthieu
9a9e5093f5 feat : add archive/unarchive to TaskGroupDrawer and fix isFinal serialization
Fix TaskStatus getter naming (isFinal -> getIsFinal) so Symfony serializer
properly exposes the isFinal field. Add archive/unarchive buttons and
non-final tasks info message to TaskGroupDrawer. Remove obsolete TaskType
entity and repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:50:41 +01:00
114 changed files with 15905 additions and 400 deletions

21
.env
View File

@@ -1,24 +1,23 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
APP_DEBUG=1
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
DEFAULT_URI=http://localhost/
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
###< lexik/jwt-authentication-bundle ###
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
ENCRYPTION_KEY=aaaaaaaaa

View File

@@ -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, TaskType, 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)
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, clients, projects, projects/[id], projects/[id]/groups, projects/[id]/statuses, 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, *Drawer, TaskCard, Admin*Tab, ProjectStatusTab, ProjectGroupTab, SidebarLink, SidebarTimer, TimeEntry*, TimeTrackingCalendar, ConfirmDeleteStatusModal)
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-types, 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/)
```

View File

@@ -22,6 +22,7 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",

176
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9482fc27494f618b2bae1b7f250e8326",
"content-hash": "4790d8c80c0fb208e5af11fb205c0202",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -4618,6 +4618,180 @@
],
"time": "2026-03-06T15:40:00+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "ade9bd433450382f0af154661fc8e72758b4de36"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36",
"reference": "ade9bd433450382f0af154661fc8e72758b4de36",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-06T13:17:40+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.7",

View File

@@ -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>,

View File

@@ -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%'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.0'
app.version: '0.1.1'

View File

@@ -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:

View File

@@ -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;

View File

@@ -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

View File

@@ -0,0 +1,816 @@
# Admin Clients + Global Statuses Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move clients management into the admin page as a tab, and make task statuses global (shared across all projects) instead of per-project.
**Architecture:** Two independent changes: (1) Extract client CRUD from its dedicated page into an `AdminClientTab` component inside the admin page, remove the standalone `/clients` page and sidebar link. (2) Remove the `project` relationship from `TaskStatus` entity, update the frontend to use `getAll()` everywhere instead of `getByProject()`, remove per-project status management pages/links, and update `AdminStatusTab` + `TaskStatusDrawer` to work without `projectId`.
**Tech Stack:** PHP 8.4 / Symfony 8 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / TypeScript (frontend)
---
## Chunk 1: Move Clients into Admin
### Task 1: Create AdminClientTab component
**Files:**
- Create: `frontend/components/admin/AdminClientTab.vue`
- [ ] **Step 1: Create AdminClientTab.vue**
Extract the logic from `frontend/pages/clients.vue` into a new admin tab component, following the same pattern as `AdminPriorityTab.vue` (h2 title instead of h1, no `useHead`).
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un client
</button>
</div>
<DataTable
:columns="columns"
:items="clients"
:loading="isLoading"
empty-message="Aucun client trouvé."
deletable
@row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
>
<template #cell-email="{ item }">
{{ item.email ?? '-' }}
</template>
<template #cell-address="{ item }">
{{ formatAddress(item) }}
</template>
<template #cell-phone="{ item }">
{{ item.phone ?? '-' }}
</template>
</DataTable>
<ClientDrawer
v-model="drawerOpen"
:client="selectedClient"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/services/dto/client'
import { useClientService } from '~/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'name', label: 'Nom', primary: true },
{ key: 'email', label: 'Email', class: 'text-primary-500' },
{ key: 'address', label: 'Adresse', class: 'text-neutral-700' },
{ key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
]
const { getAll, remove } = useClientService()
const clients = ref<Client[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
async function loadClients() {
isLoading.value = true
try {
clients.value = await getAll()
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedClient.value = null
drawerOpen.value = true
}
function openEdit(client: Client) {
selectedClient.value = client
drawerOpen.value = true
}
function formatAddress(client: Client): string {
return [client.street, client.postalCode, client.city]
.filter(Boolean)
.join(', ') || '-'
}
async function handleDelete(id: number) {
await remove(id)
await loadClients()
}
async function onSaved() {
await loadClients()
}
onMounted(() => {
loadClients()
})
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/admin/AdminClientTab.vue
git commit -m "feat(admin) : add AdminClientTab component"
```
### Task 2: Add Clients tab to admin page and remove standalone page
**Files:**
- Modify: `frontend/pages/admin.vue`
- Delete: `frontend/pages/clients.vue`
- [ ] **Step 3: Update admin.vue to include Clients tab**
Add `AdminClientTab` to the admin page. Add the tab entry to the `tabs` array and the corresponding `v-if` block:
In `frontend/pages/admin.vue`, update the `tabs` array:
```typescript
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
{ key: 'types', label: 'Types' },
{ key: 'users', label: 'Utilisateurs' },
] as const
```
Change the default active tab:
```typescript
const activeTab = ref<TabKey>('clients')
```
Add the component in the template `<div class="mt-6">` block:
```html
<AdminClientTab v-if="activeTab === 'clients'" />
```
- [ ] **Step 4: Delete standalone clients page**
Delete `frontend/pages/clients.vue`.
- [ ] **Step 5: Commit**
```bash
git add frontend/pages/admin.vue
git rm frontend/pages/clients.vue
git commit -m "feat(admin) : move clients into admin page, remove standalone page"
```
### Task 3: Remove clients sidebar link
**Files:**
- Modify: `frontend/layouts/default.vue`
- [ ] **Step 6: Remove the Clients SidebarLink from default.vue**
Remove the following block from `frontend/layouts/default.vue` (lines 60-65):
```html
<SidebarLink
to="/clients"
icon="mdi:account-group-outline"
label="Clients"
:collapsed="ui.sidebarCollapsed"
/>
```
- [ ] **Step 7: Commit**
```bash
git add frontend/layouts/default.vue
git commit -m "refactor(frontend) : remove clients sidebar link"
```
---
## Chunk 2: Make Task Statuses Global
### Task 4: Remove project relationship from TaskStatus entity
**Files:**
- Modify: `src/Entity/TaskStatus.php`
- [ ] **Step 8: Update TaskStatus entity to remove project relationship**
In `src/Entity/TaskStatus.php`:
1. Remove the `SearchFilter` import and `#[ApiFilter]` attribute
2. Remove the `$project` property, its `#[ORM]` annotations, and the `getProject()`/`setProject()` methods
The entity should become:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_status:read']],
denormalizationContext: ['groups' => ['task_status:write']],
order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
class TaskStatus
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_status:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
private ?string $color = '#222783';
#[ORM\Column]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
private ?int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
public function getPosition(): ?int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
```
- [ ] **Step 9: Commit**
```bash
git add src/Entity/TaskStatus.php
git commit -m "refactor(backend) : remove project relationship from TaskStatus entity"
```
### Task 5: Generate and run Doctrine migration
- [ ] **Step 10: Generate the migration**
```bash
make shell
# Inside container:
php bin/console doctrine:migrations:diff
exit
```
This should generate a migration that:
- Drops the `project_id` foreign key from `task_status` table
- Drops the `project_id` column from `task_status` table
- [ ] **Step 11: Review the migration**
Read the generated migration file in `migrations/` to verify it only drops the FK and column.
- [ ] **Step 12: Reset database (since structure changed significantly)**
```bash
make db-reset
```
- [ ] **Step 13: Commit**
```bash
git add migrations/
git commit -m "feat(backend) : add migration to remove project_id from task_status"
```
### Task 6: Update fixtures for global statuses
**Files:**
- Modify: `src/DataFixtures/AppFixtures.php`
- [ ] **Step 14: Update fixtures to create global statuses instead of per-project**
In `src/DataFixtures/AppFixtures.php`, replace the entire per-project status block (lines 95-124, from `// Task Statuses (per project)` through `$statusDone = $sirhStatuses['Terminé'];`) with global creation:
```php
// Task Statuses (global)
$defaultStatuses = [
['A faire', '#222783', 0],
['En cours', '#4A90D9', 1],
['Bloqué', '#C62828', 2],
['En attente de validation', '#FF8F00', 3],
['Terminé', '#26A69A', 4],
];
$statusObjects = [];
foreach ($defaultStatuses as [$label, $color, $position]) {
$status = new TaskStatus();
$status->setLabel($label);
$status->setColor($color);
$status->setPosition($position);
$manager->persist($status);
$statusObjects[$label] = $status;
}
$statusTodo = $statusObjects['A faire'];
$statusInProgress = $statusObjects['En cours'];
$statusBlocked = $statusObjects['Bloqué'];
$statusReview = $statusObjects['En attente de validation'];
$statusDone = $statusObjects['Terminé'];
```
This replaces the loop that created statuses per-project AND the `$statusesByProject` / `$sirhStatuses` extraction lines (95-124). The task variable references (`$statusTodo`, etc.) remain identical so downstream task creation is unchanged.
- [ ] **Step 15: Reload fixtures to verify**
```bash
make db-reset
```
- [ ] **Step 16: Commit**
```bash
git add src/DataFixtures/AppFixtures.php
git commit -m "fix(fixtures) : create global statuses instead of per-project"
```
### Task 7: Update frontend DTO and service for global statuses
**Files:**
- Modify: `frontend/services/dto/task-status.ts`
- Modify: `frontend/services/task-statuses.ts`
- [ ] **Step 17: Update TaskStatus DTO to remove project field**
In `frontend/services/dto/task-status.ts`, remove the `project` import and field from both types:
```typescript
export type TaskStatus = {
id: number
'@id'?: string
label: string
color: string
position: number
}
export type TaskStatusWrite = {
label: string
color: string
position: number
}
```
- [ ] **Step 18: Remove getByProject from task-statuses service**
In `frontend/services/task-statuses.ts`, remove the `getByProject` function and its return:
```typescript
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskStatusService() {
const api = useApi()
async function getAll(): Promise<TaskStatus[]> {
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
return extractHydraMembers(data)
}
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
toastSuccessKey: 'taskStatuses.created',
})
}
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskStatuses.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_statuses/${id}`, {}, {
toastSuccessKey: 'taskStatuses.deleted',
})
}
return { getAll, create, update, remove }
}
```
- [ ] **Step 19: Commit**
```bash
git add frontend/services/dto/task-status.ts frontend/services/task-statuses.ts
git commit -m "refactor(frontend) : remove project from TaskStatus DTO and service"
```
### Task 8: Update TaskStatusDrawer to remove projectId
**Files:**
- Modify: `frontend/components/task/TaskStatusDrawer.vue`
- [ ] **Step 20: Remove projectId prop from TaskStatusDrawer**
In `frontend/components/task/TaskStatusDrawer.vue`:
1. Remove `projectId` from props:
```typescript
const props = defineProps<{
modelValue: boolean
item: TaskStatus | null
}>()
```
2. Remove the `project` field from the payload in `handleSubmit`:
```typescript
const payload: TaskStatusWrite = {
label: form.label.trim(),
position: Number(form.position),
color: form.color,
}
```
- [ ] **Step 21: Commit**
```bash
git add frontend/components/task/TaskStatusDrawer.vue
git commit -m "refactor(frontend) : remove projectId from TaskStatusDrawer"
```
### Task 9: Add getAll to task service and update AdminStatusTab
**Files:**
- Modify: `frontend/services/tasks.ts`
- Modify: `frontend/components/admin/AdminStatusTab.vue`
- [ ] **Step 22: Add getAll() method to task service**
`frontend/services/tasks.ts` currently only has `getByProject()`. Add a `getAll` function (needed by AdminStatusTab to check all tasks across projects when deleting a status).
Add this function inside `useTaskService()`, before `getByProject`:
```typescript
async function getAll(): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks')
return extractHydraMembers(data)
}
```
Update the return statement to include it:
```typescript
return { getAll, getByProject, create, update, remove }
```
- [ ] **Step 23: Update AdminStatusTab to handle task reassignment on delete**
The existing `AdminStatusTab` does a simple `remove(id)` which would leave tasks orphaned. Port the reassignment logic from `ProjectStatusTab` (which is being deleted). Since statuses are now global, we need to load ALL tasks (not per-project) to check for affected tasks.
Replace the full content of `frontend/components/admin/AdminStatusTab.vue` with:
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un statut
</button>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun statut trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-color="{ item }">
<span
class="inline-block h-6 w-6 rounded-full"
:style="{ backgroundColor: item.color }"
/>
</template>
</DataTable>
<TaskStatusDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
<ConfirmDeleteStatusModal
v-model="confirmModalOpen"
:status-label="statusToDelete?.label ?? ''"
:task-count="affectedTaskCount"
:available-statuses="reassignTargets"
@confirm="onConfirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import type { Task } from '~/services/dto/task'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskService } from '~/services/tasks'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'label', label: 'Libellé', primary: true },
{ key: 'color', label: 'Couleur' },
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
]
const statusService = useTaskStatusService()
const taskService = useTaskService()
const items = ref<TaskStatus[]>([])
const tasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)
const confirmModalOpen = ref(false)
const statusToDelete = ref<TaskStatus | null>(null)
const affectedTaskCount = computed(() => {
if (!statusToDelete.value) return 0
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
})
const reassignTargets = computed(() => {
if (!statusToDelete.value) return items.value
return items.value.filter(s => s.id !== statusToDelete.value!.id)
})
async function loadItems() {
isLoading.value = true
try {
const [statuses, allTasks] = await Promise.all([
statusService.getAll(),
taskService.getAll(),
])
items.value = statuses
tasks.value = allTasks
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: TaskStatus) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: TaskStatus) {
statusToDelete.value = item
const count = tasks.value.filter(t => t.status?.id === item.id).length
if (count === 0) {
await statusService.remove(item.id)
await loadItems()
} else {
confirmModalOpen.value = true
}
}
async function onConfirmDelete(targetStatusId: number | null) {
if (!statusToDelete.value) return
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
await Promise.all(
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
)
await statusService.remove(statusToDelete.value.id)
confirmModalOpen.value = false
statusToDelete.value = null
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>
```
- [ ] **Step 24: Commit**
```bash
git add frontend/services/tasks.ts frontend/components/admin/AdminStatusTab.vue
git commit -m "feat(admin) : add task reassignment logic to AdminStatusTab"
```
### Task 10: Add Statuts tab to admin page
**Files:**
- Modify: `frontend/pages/admin.vue`
- [ ] **Step 24: Add Statuts tab to admin.vue**
In `frontend/pages/admin.vue`, update the `tabs` array to include statuses:
```typescript
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'statuses', label: 'Statuts' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
{ key: 'types', label: 'Types' },
{ key: 'users', label: 'Utilisateurs' },
] as const
```
Add the component in the template:
```html
<AdminStatusTab v-if="activeTab === 'statuses'" />
```
- [ ] **Step 25: Commit**
```bash
git add frontend/pages/admin.vue
git commit -m "feat(admin) : add statuts tab to admin page"
```
### Task 11: Update kanban page to use global statuses
**Files:**
- Modify: `frontend/pages/projects/[id]/index.vue`
- [ ] **Step 26: Change kanban to load global statuses**
In `frontend/pages/projects/[id]/index.vue`, in the `loadData` function, change:
```typescript
statusService.getByProject(projectId.value),
```
to:
```typescript
statusService.getAll(),
```
- [ ] **Step 27: Commit**
```bash
git add frontend/pages/projects/[id]/index.vue
git commit -m "refactor(frontend) : load global statuses in kanban page"
```
### Task 12: Remove per-project status pages, sidebar link, and orphaned components
**Files:**
- Delete: `frontend/pages/projects/[id]/statuses.vue`
- Delete: `frontend/components/project/ProjectStatusTab.vue`
- Modify: `frontend/layouts/default.vue`
- [ ] **Step 28: Delete per-project statuses page and ProjectStatusTab**
```bash
git rm frontend/pages/projects/[id]/statuses.vue
git rm frontend/components/project/ProjectStatusTab.vue
```
- [ ] **Step 29: Remove statuses SidebarLink from default.vue**
In `frontend/layouts/default.vue`, remove the statuses sidebar link block (inside the `v-if="currentProjectId"` template, lines 52-58):
```html
<SidebarLink
:to="`/projects/${currentProjectId}/statuses`"
icon="mdi:list-status"
label="Statuts"
:collapsed="ui.sidebarCollapsed"
sub
/>
```
- [ ] **Step 30: Commit**
```bash
git add frontend/layouts/default.vue
git commit -m "refactor(frontend) : remove per-project statuses page and sidebar link"
```
### Task 13: Verify and clean up
- [ ] **Step 31: Check for remaining references to getByProject in task-statuses**
Search for any remaining `getByProject` calls on the status service and `projectId` references in status-related components:
```bash
cd /home/matthieu/dev_malio/Lesstime
grep -rn "getByProject\|projectId" frontend/ --include="*.vue" --include="*.ts" | grep -i status
grep -rn "ConfirmDeleteStatusModal\|ProjectStatusTab" frontend/ --include="*.vue" --include="*.ts"
```
Fix any remaining references found.
- [ ] **Step 32: Run the dev server and verify**
```bash
make db-reset && make dev-nuxt
```
Verify:
1. Admin page shows Clients tab with full CRUD (create, edit, delete)
2. Admin page shows Statuts tab with global statuses CRUD
3. Sidebar no longer shows "Clients" or per-project "Statuts" links
4. Kanban board displays all global statuses as columns
5. No errors in browser console
- [ ] **Step 33: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore : clean up remaining references after global statuses refactor"
```

View File

@@ -0,0 +1,158 @@
# Time Entry Multi-Type Selection Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow selecting multiple task types in the TimeEntryDrawer, matching the existing multi-select pattern used in TaskDrawer.
**Architecture:** Replace the single-select `MalioSelect` dropdown for types with the checkbox-based colored badge multi-select already used in TaskDrawer. The backend (ManyToMany relation) and DTO (`types: string[]`) already support multiple types — only the frontend form state and template need updating.
**Tech Stack:** Vue 3, TypeScript
---
## Chunk 1: Multi-Type Select in TimeEntryDrawer
### Task 1: Update TimeEntryDrawer to support multiple type selection
**Files:**
- Modify: `frontend/components/time-tracking/TimeEntryDrawer.vue`
- [ ] **Step 1: Change form state from single typeId to typeIds array**
In the `form` reactive object (line 133-142), replace:
```typescript
typeId: null as number | null,
```
with:
```typescript
typeIds: [] as number[],
```
- [ ] **Step 2: Add toggleType function**
Add this function after the `durationLabel` computed (after line 165):
```typescript
function toggleType(id: number) {
const idx = form.typeIds.indexOf(id)
if (idx >= 0) {
form.typeIds.splice(idx, 1)
} else {
form.typeIds.push(id)
}
}
```
- [ ] **Step 3: Remove the typeOptions computed**
Delete the `typeOptions` computed (lines 152-154):
```typescript
const typeOptions = computed(() =>
props.types.map(t => ({ label: t.label, value: t.id }))
)
```
This is no longer needed since we won't use `MalioSelect`.
- [ ] **Step 4: Replace MalioSelect template with multi-select badges**
Replace the `MalioSelect` for type (lines 75-81):
```vue
<MalioSelect
v-model="form.typeId"
:options="typeOptions"
label="Type"
empty-option-label=" Aucun "
min-width="w-full"
/>
```
with:
```vue
<div>
<p class="mb-2 text-sm font-semibold text-neutral-700">Types</p>
<div class="flex flex-wrap gap-2">
<label
v-for="type in types"
:key="type.id"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
:class="form.typeIds.includes(type.id)
? 'text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
>
<input
type="checkbox"
class="hidden"
:value="type.id"
:checked="form.typeIds.includes(type.id)"
@change="toggleType(type.id)"
/>
{{ type.label }}
</label>
</div>
</div>
```
- [ ] **Step 5: Update populateForm to use typeIds**
In the `populateForm` function, replace (line 194):
```typescript
form.typeId = entry.types?.[0]?.id ?? null
```
with:
```typescript
form.typeIds = entry.types?.map(t => t.id) ?? []
```
And in the else branch (line 203), replace:
```typescript
form.typeId = null
```
with:
```typescript
form.typeIds = []
```
- [ ] **Step 6: Update onSubmit payload to use typeIds**
In the `onSubmit` function, replace (line 233):
```typescript
types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
```
with:
```typescript
types: form.typeIds.map(id => `/api/task_types/${id}`),
```
- [ ] **Step 7: Verify in browser**
Run: `make dev-nuxt`
1. Open time tracking page
2. Open an existing time entry → verify existing types are pre-selected as colored badges
3. Toggle types on/off → verify visual feedback (colored background when selected)
4. Save → verify types are persisted correctly
5. Create a new time entry with multiple types → verify they save correctly
- [ ] **Step 8: Commit**
```bash
git add frontend/components/time-tracking/TimeEntryDrawer.vue
git commit -m "feat(frontend) : allow multiple type selection in time entry drawer"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,584 @@
# My Tasks Page Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a "/my-tasks" page that displays all non-archived tasks across projects with Kanban and List views, filtered by current user by default.
**Architecture:** Backend: add SearchFilter annotations on Task entity for server-side filtering. Frontend: new page with filter bar + two view modes (Kanban/List), reusing existing TaskCard and TaskModal components.
**Tech Stack:** PHP 8.4 / API Platform 4 (SearchFilter), Nuxt 4 / Vue 3, Tailwind CSS, MalioSelect, Pinia
**Spec:** `docs/superpowers/specs/2026-03-13-my-tasks-page-design.md`
---
## Chunk 1: Backend — API Filters
### Task 1: Add SearchFilter annotations on Task entity
**Files:**
- Modify: `src/Entity/Task.php:35` (ApiFilter line)
- [ ] **Step 1: Add new SearchFilter properties**
In `src/Entity/Task.php`, replace the existing `#[ApiFilter(SearchFilter::class, ...)]` line (line 35) with an expanded version that includes `assignee`, `priority`, `effort`, `tags`, and `status`:
```php
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
```
- [ ] **Step 2: Disable pagination on GetCollection**
In `src/Entity/Task.php`, modify the `GetCollection` operation (line 25) to disable pagination:
```php
new GetCollection(paginationEnabled: false),
```
- [ ] **Step 3: Verify filters work**
Run in the container:
```bash
docker exec -t php-lesstime-fpm php bin/console debug:router | grep tasks
```
Then test the API call:
```bash
curl -s 'http://localhost:8082/api/tasks?archived=false&assignee=/api/users/1' -H 'Cookie: BEARER=...' | head -c 500
```
Expected: JSON response with filtered tasks.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Task.php
git commit -m "feat(backend) : add SearchFilter for assignee, priority, effort, tags, status on Task"
```
---
## Chunk 2: Frontend — Service, i18n, Sidebar
### Task 2: Add `getFiltered` method to task service
**Files:**
- Modify: `frontend/services/tasks.ts`
- [ ] **Step 1: Add the `getFiltered` method**
Add after the `getByProjectArchived` method (after line 27) in `frontend/services/tasks.ts`:
```typescript
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
return extractHydraMembers(data)
}
```
- [ ] **Step 2: Export the new method**
Update the return statement (line 47) to include `getFiltered`:
```typescript
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
```
- [ ] **Step 3: Commit**
```bash
git add frontend/services/tasks.ts
git commit -m "feat(frontend) : add getFiltered method to task service"
```
### Task 3: Add i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add myTasks and sidebar keys**
Add these entries to `frontend/i18n/locales/fr.json` (before the closing `}`):
```json
"myTasks": {
"title": "Mes tâches",
"viewKanban": "Vue Kanban",
"viewList": "Vue Liste",
"allProjects": "Tous les projets",
"allGroups": "Tous les groupes",
"allTypes": "Tous les types",
"allPriorities": "Toutes les priorités",
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog"
},
"sidebar": {
"myTasks": "Mes tâches"
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add i18n translations for my-tasks page"
```
### Task 4: Add sidebar navigation link
**Files:**
- Modify: `frontend/layouts/default.vue:23-35` (nav section)
- [ ] **Step 1: Add SidebarLink for "Mes tâches"**
In `frontend/layouts/default.vue`, add a new `SidebarLink` between the "Tableau de bord" link (line 29) and the "Projets" link (line 30):
```vue
<SidebarLink
to="/my-tasks"
icon="mdi:clipboard-check-outline"
label="Mes tâches"
:collapsed="ui.sidebarCollapsed"
/>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/layouts/default.vue
git commit -m "feat(frontend) : add Mes tâches link to sidebar navigation"
```
---
## Chunk 3: Frontend — My Tasks Page (Kanban + List views)
### Task 5: Create the my-tasks page
**Files:**
- Create: `frontend/pages/my-tasks.vue`
- [ ] **Step 1: Create the page file with imports and data loading**
Create `frontend/pages/my-tasks.vue` with the full page implementation. The page structure:
**Script section** — data loading pattern (same as `projects/[id]/index.vue`):
```typescript
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('myTasks.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
const groupService = useTaskGroupService()
const userService = useUserService()
const projectService = useProjectService()
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const isLoading = ref(true)
// Filters
const selectedProjectId = ref<number | null>(null)
const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
// Filter options
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const groupOptions = computed(() => {
let g = groups.value.filter(g => !g.archived)
if (selectedProjectId.value) {
g = g.filter(g => g.project?.id === selectedProjectId.value)
}
return g.map(g => ({ label: g.title, value: g.id }))
})
const tagOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
const priorityOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id }))
)
const effortOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const assigneeOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
}
const backlogTasks = computed(() =>
tasks.value.filter(t => !t.status)
)
// Data loading
async function loadReferenceData() {
const [s, e, pr, tg, g, u, p] = await Promise.all([
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
groupService.getAll(),
userService.getAll(),
projectService.getAll(),
])
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = tg
groups.value = g
users.value = u
projects.value = p
}
async function loadTasks() {
const params: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (selectedAssigneeId.value) {
params.assignee = `/api/users/${selectedAssigneeId.value}`
}
if (selectedProjectId.value) {
params.project = `/api/projects/${selectedProjectId.value}`
}
if (selectedGroupId.value) {
params.group = `/api/task_groups/${selectedGroupId.value}`
}
if (selectedPriorityId.value) {
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
}
if (selectedEffortId.value) {
params.effort = `/api/task_efforts/${selectedEffortId.value}`
}
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
tasks.value = await taskService.getFiltered(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks()])
} finally {
isLoading.value = false
}
}
// Watch filters to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
() => { loadTasks() },
)
// Reset group when project changes (no extra loadTasks — the above watcher handles it)
watch(selectedProjectId, () => {
selectedGroupId.value = null
}, { flush: 'sync' })
// Modal
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
}
async function onSaved() {
await loadTasks()
}
onMounted(() => {
loadAll()
})
</script>
```
**Template section:**
```vue
<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>
</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-48"
/>
<MalioSelect
v-model="selectedGroupId"
:options="groupOptions"
label="Groupe"
:empty-option-label="$t('myTasks.allGroups')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
label="Type"
:empty-option-label="$t('myTasks.allTypes')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityOptions"
label="Priorité"
:empty-option-label="$t('myTasks.allPriorities')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortOptions"
label="Effort"
:empty-option-label="$t('myTasks.allEfforts')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="assigneeOptions"
label="Assigné"
:empty-option-label="$t('myTasks.allAssignees')"
min-width="w-48"
/>
</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="backlogTasks.length > 0"
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
>
<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">
<TaskCard
v-for="task in backlogTasks"
:key="task.id"
:task="task"
@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 bg-neutral-50"
>
<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>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<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"
@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">
<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-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>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
<!-- TaskModal -->
<TaskModal
v-model="taskModalOpen"
:task="selectedTask"
:project-id="selectedTask?.project?.id ?? 0"
:statuses="statuses"
:efforts="efforts"
:priorities="priorities"
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
@saved="onSaved"
/>
</div>
</template>
```
Note: add the `timerStore` and `isTimerOnTask` helper in the script section:
```typescript
const timerStore = useTimerStore()
function isTimerOnTask(task: Task): boolean {
const entry = timerStore.activeEntry
if (!entry?.task) return false
const entryTaskId = typeof entry.task === 'string'
? entry.task
: (entry.task['@id'] ?? entry.task.id)
const taskId = task['@id'] ?? task.id
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
}
```
- [ ] **Step 2: Verify the page loads**
Run: `make dev-nuxt`
Navigate to `http://localhost:3002/my-tasks`.
Expected: page loads with filters and shows tasks assigned to current user in Kanban view.
- [ ] **Step 3: Test view toggle**
Click the list icon. Expected: tasks display in list format with title, badges, project code.
Click the kanban icon. Expected: tasks display in columns by status.
- [ ] **Step 4: Test filters**
Change the assignee filter to "Tous". Expected: all tasks from all users appear.
Select a specific project. Expected: only tasks from that project appear.
Reset all filters. Expected: all non-archived tasks appear.
- [ ] **Step 5: Test TaskModal integration**
Click on a task card/row. Expected: TaskModal opens with task details pre-filled.
Edit and save. Expected: modal closes, tasks reload with updated data.
- [ ] **Step 6: Commit**
```bash
git add frontend/pages/my-tasks.vue
git commit -m "feat(frontend) : add my-tasks page with Kanban and List views"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,4 +1,4 @@
# Feature: Archivage de tickets et de groupes
o# Feature: Archivage de tickets et de groupes
## Résumé
@@ -35,6 +35,7 @@ Une migration Doctrine unique pour les 3 champs (`task_status.is_final`, `task.a
### Archivage de groupe (bulk)
L'archivage d'un groupe est une opération frontend multi-appels :
1. PATCH chaque ticket du groupe avec `{ archived: true }`
2. PATCH le groupe avec `{ archived: true }`
@@ -53,17 +54,20 @@ La règle "archiver seulement si statut final" est appliquée côté frontend (v
### TaskDrawer — archivage et modale suppression
**Bouton "Archiver"** :
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
- PATCH `{ archived: true }` sur le ticket
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
- Ferme le drawer et rafraîchit la liste des tickets
**Bouton "Désarchiver"** :
- Visible quand on consulte un ticket archivé (depuis la page archives)
- PATCH `{ archived: false }`
- Ferme le drawer et rafraîchit la page archives
**Modale de confirmation de suppression** :
- Déclenchée au clic sur "Supprimer" dans le TaskDrawer
- Message : "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
- Deux boutons : "Annuler" / "Supprimer" (style destructif, rouge)
@@ -84,11 +88,13 @@ La règle "archiver seulement si statut final" est appliquée côté frontend (v
**Toggle "Voir les groupes archivés"** : bascule pour afficher les groupes archivés.
**Bouton "Archiver" sur un groupe** :
- Visible uniquement si le groupe a au moins un ticket ET que **tous** ses tickets ont un statut `isFinal: true` (un ticket sans statut bloque l'archivage)
- Archive tous les tickets du groupe puis le groupe lui-même (appels PATCH séquentiels)
- Rafraîchit la liste
**Bouton "Désarchiver" sur un groupe archivé** :
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
- Rafraîchit la liste
@@ -123,6 +129,7 @@ Ajout du champ `archived: boolean` dans les types `TaskGroup` et `TaskGroupWrite
## Traductions (i18n)
Clés à ajouter dans `fr.json` :
- `task.archive` / `task.unarchive`
- `task.delete_confirm_title` / `task.delete_confirm_message`
- `group.archive` / `group.unarchive`

View File

@@ -0,0 +1,151 @@
# Intégration Gitea — Design Spec
## Objectif
Lier les tickets Lesstime à Gitea pour :
- Créer une branche depuis un ticket (avec choix du type : feature, fix, refactor, etc.)
- Voir les branches, commits, PRs et statut CI liés à un ticket
- Le tout à la demande (pas de webhook, pas de cron, pas de stockage git en base)
## Décisions
| Question | Décision |
|----------|----------|
| Interaction | Depuis Lesstime uniquement (bouton + affichage) |
| Repos | Un projet = un repo Gitea |
| Nommage branches | `<type>/PROJ-42-titre-en-slug` (type choisi par l'utilisateur) |
| Détection commits | Par la branche (tous les commits d'une branche liée au ticket) |
| Données affichées | Branches + commits + PRs + statut CI/CD |
| Config serveur | Globale (admin) : URL + token API |
| Config repo | Par projet : owner + repo name |
| Synchronisation | À la demande (appel API Gitea en temps réel) |
## Architecture
### Backend
#### Configuration globale — entité `GiteaConfiguration`
Entité dédiée (singleton en base, un seul enregistrement) :
```
id: int
url: string|null (ex: "https://git.malio.fr")
token: string|null (token API personnel Gitea, chiffré via SodiumEncryptor)
```
Le token est chiffré au repos avec une clé symétrique définie en variable d'environnement (`GITEA_ENCRYPTION_KEY`). L'endpoint GET ne retourne jamais le token en clair — seulement un booléen `hasToken`.
#### Entité `Project` — nouveaux champs
```
gitea_owner: string|null (ex: "malio")
gitea_repo: string|null (ex: "lesstime")
```
Nullable car tous les projets ne sont pas forcément liés à un repo.
#### Service `GiteaApiService`
Service Symfony qui encapsule les appels HTTP vers l'API Gitea REST v1.
**Configuration HTTP :**
- Timeout : 10s par requête
- En cas d'erreur Gitea (timeout, 5xx, réseau), le service lève une `GiteaApiException` avec un message clair. Le frontend affiche un état dégradé (message d'erreur dans la section Git, le reste de la TaskModal fonctionne normalement).
**Méthodes :**
- `testConnection(): bool` — appelle `GET /api/v1/version` pour vérifier la connexion
- `listRepositories(): array` — liste les repos accessibles (pour sélection dans le projet)
- `getDefaultBranch(project): string` — récupère la branche par défaut du repo via `GET /api/v1/repos/{owner}/{repo}`
- `createBranch(project, task, type, baseBranch): string` — crée une branche `<type>/CODE-NUM-slug` à partir d'une branche de base
- `listBranches(project, taskCode): array` — liste les branches matchant le pattern `*/CODE-NUM-*` ou `*/CODE-NUM` (délimité pour éviter de matcher PROJ-420 quand on cherche PROJ-42)
- `listCommits(project, branch): array` — liste les commits d'une branche (paginé, max 30)
- `listPullRequests(project, taskCode): array` — liste les PRs en filtrant par `head` branch (paramètre `?head=` supporté par Gitea)
- `getPullRequestChecks(project, prNumber): array` — statut CI/CD d'une PR
- `copyBranchName(task, type): string` — génère le nom de branche sans appeler Gitea (pour le bouton "copier")
**Slug :** généré côté backend avec `Symfony\Component\String\Slugger\AsciiSlugger` — gère les accents français, tronqué à 50 caractères max pour le slug (le nom complet de branche reste sous 80 chars).
#### Endpoints API Platform
Nouveaux endpoints :
- `GET /api/settings/gitea` — récupérer la config Gitea (admin only, retourne url + hasToken)
- `PUT /api/settings/gitea` — sauvegarder la config Gitea (admin only)
- `POST /api/settings/gitea/test` — tester la connexion Gitea (admin only)
- `GET /api/gitea/repositories` — lister les repos disponibles (pour config projet)
- `POST /api/tasks/{id}/gitea/branches` — créer une branche pour un ticket
- `GET /api/tasks/{id}/gitea/branches` — lister branches liées au ticket
- `GET /api/tasks/{id}/gitea/pull-requests` — lister PRs liées au ticket (avec statut CI inclus)
Les endpoints branches et PRs sont séparés pour permettre un chargement progressif côté frontend et éviter de fan-out trop de requêtes Gitea en un seul appel.
### Frontend
#### Service `frontend/services/gitea.ts`
Nouveau service API encapsulant les appels, cohérent avec le pattern existant (`frontend/services/`).
#### Admin — Config Gitea
Nouveau tab `GiteaAdminTab.vue` dans l'admin pour configurer :
- URL du serveur Gitea
- Token API (champ password, affiche seulement si un token est configuré)
- Bouton "Tester la connexion"
#### ProjectDrawer — Config repo
Ajout de champs dans le drawer de projet :
- Sélecteur de repo Gitea (dropdown alimenté par `GET /api/gitea/repositories`)
- Affiche `owner/repo` une fois sélectionné
#### TaskModal — Section Git
Nouveau composant `TaskGitSection.vue` intégré dans la TaskModal (visible et chargé uniquement si le projet a un repo configuré).
**Bouton "Créer une branche"** :
- Sélecteur de type : `feature`, `fix`, `refactor`, `hotfix`, `chore`
- Sélecteur de branche de base (default: branche par défaut du repo)
- Preview du nom : `feature/PROJ-42-titre-de-la-tache`
- Bouton de confirmation
- Bouton "Copier le nom" (génère le nom sans appeler Gitea, pour création locale)
**Affichage des infos Git** (chargement progressif) :
- Liste des branches liées (avec statut : active / mergée / supprimée)
- Pour chaque branche : derniers commits (hash court, message, auteur, date)
- PRs associées (titre, statut : open/merged/closed, reviewers)
- Statut CI/CD par PR (checks : success/failure/pending)
- Liens directs vers Gitea pour chaque élément
- En cas d'erreur Gitea : message d'erreur dans la section, le reste de la modal reste fonctionnel
### Sécurité
- Le token Gitea est chiffré en base via `SodiumEncryptor` avec clé `GITEA_ENCRYPTION_KEY`
- L'endpoint `GET /api/settings/gitea` retourne `url` + `hasToken: bool`, jamais le token en clair
- Seuls les `ROLE_ADMIN` peuvent configurer le serveur Gitea et les repos
- Les utilisateurs authentifiés peuvent créer des branches et voir les infos git pour les tâches qu'ils ont le droit de voir
### i18n
Toutes les chaînes UI (labels, messages d'erreur, types de branche) passent par le système i18n existant (`frontend/i18n/locales/fr.json` et `en.json`).
## API Gitea — Endpoints utilisés
| Action | Méthode Gitea API |
|--------|-------------------|
| Tester connexion | `GET /api/v1/version` |
| Info repo (branche défaut) | `GET /api/v1/repos/{owner}/{repo}` |
| Lister repos | `GET /api/v1/repos/search` |
| Lister branches | `GET /api/v1/repos/{owner}/{repo}/branches` |
| Créer branche | `POST /api/v1/repos/{owner}/{repo}/branches` |
| Lister commits | `GET /api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=30` |
| Lister PRs par branche | `GET /api/v1/repos/{owner}/{repo}/pulls?state=all&head={branch}` |
| Statut CI | `GET /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` |
## Hors scope
- Webhooks Gitea → Lesstime
- Stockage des commits/PRs en base
- Création de PR depuis Lesstime
- Lien multi-repo par projet
- Synchronisation périodique (cron/polling)

View File

@@ -0,0 +1,135 @@
# Feature: Page "Mes tâches"
## Résumé
Page dédiée `/my-tasks` affichant toutes les tâches non-archivées de tous les projets, avec filtrage côté serveur. Deux vues : Kanban (colonnes par statut) et Liste. Par défaut filtrée sur l'utilisateur courant, avec possibilité de changer l'assigné, voir tous les utilisateurs, et filtrer par projet/groupe/type/priorité/effort.
## Backend
### Filtres API Platform sur Task
Ajouter des `SearchFilter` sur l'entité `Task` pour les champs suivants (en plus des filtres existants `project`, `group`, `archived`) :
- `assignee` — filtre exact (par id)
- `priority` — filtre exact
- `effort` — filtre exact
- `tags` — filtre exact (ManyToMany, query param format : `tags[]=/api/task_tags/1`)
- `status` — filtre exact
Désactiver la pagination sur l'opération `GetCollection` de Task (`paginationEnabled: false`) pour charger toutes les tâches filtrées en un seul appel.
Aucune migration nécessaire — les filtres sont purement déclaratifs sur des relations existantes.
Exemple d'appel :
```
GET /api/tasks?assignee=/api/users/1&archived=false&project=/api/projects/2&tags[]=/api/task_tags/3
```
## Frontend
### Nouvelle page : `frontend/pages/my-tasks.vue`
#### Barre de filtres
Une ligne horizontale de `MalioSelect` :
| Filtre | Options | Défaut |
|--------|---------|--------|
| Projet | Tous les projets de l'utilisateur | Tous |
| Groupe | Groupes (filtrés par projet sélectionné si applicable) | Tous |
| Type (tags) | Tous les tags | Tous |
| Priorité | Toutes les priorités | Tous |
| Effort | Tous les efforts | Tous |
| Assigné | Tous les utilisateurs + option "Tous" | Utilisateur courant |
#### Toggle de vue
Deux boutons icônes en haut à droite (à côté des filtres) :
- Icône grille/kanban → vue Kanban
- Icône liste → vue Liste
État persisté en localStorage ou simple ref (pas critique).
#### Vue Kanban
- Colonnes par statut (ordonnées par `position`), même layout que `projects/[id]/index.vue`
- Chaque colonne contient les `TaskCard` filtrés ayant ce statut
- Les tâches sans statut vont dans une section "Backlog" en première colonne
- Les colonnes de statut sans tâches sont affichées (cohérent avec la vue projet)
- Pas de drag-and-drop (pas pertinent en contexte cross-projet)
#### Vue Liste
- Lignes de tâches avec :
- Titre (bold)
- Badges : priorité (couleur) + tags (couleur)
- Code projet + numéro de tâche (ex: `SIRH-1`) — aligné à droite
- Icône timer (comme sur les TaskCard)
- Pas d'affichage des temps (exclu du périmètre)
- Séparation visuelle légère entre les lignes (border-bottom ou alternance de fond)
#### Chargement des données
Au montage, chargement parallèle :
```typescript
const [tasks, statuses, projects, efforts, priorities, tags, groups, users] = await Promise.all([
taskService.getFiltered({ assignee: currentUserId, archived: false }),
statusService.getAll(),
projectService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
groupService.getAll(),
userService.getAll()
])
```
À chaque changement de filtre → nouvel appel API pour les tâches uniquement. Les données de référence (statuts, projets, etc.) ne sont chargées qu'une fois.
#### Interactions
- Clic sur une TaskCard ou ligne de liste → ouvre le `TaskModal` en mode édition
- Après save/delete dans le modal → rechargement des tâches
- Timer sur les cartes/lignes fonctionne via le `useTimerStore` existant
### Service tasks — nouvelle méthode
Ajout dans `frontend/services/tasks.ts` :
```typescript
getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]>
```
Construit les query params à partir de l'objet et appelle `GET /api/tasks?...`. Convertit les ids en IRIs si nécessaire. Gère les paramètres tableau pour les relations ManyToMany (`tags[]`).
### Navigation
Nouveau `SidebarLink` dans `frontend/layouts/default.vue` :
- Label : "Mes tâches"
- Icône : `mdi:clipboard-check-outline` (ou similaire)
- Position : entre "Tableau de bord" et "Projets"
- Route : `/my-tasks`
### Traductions (i18n)
Clés à ajouter dans `fr.json` :
- `myTasks.title` : "Mes tâches"
- `myTasks.viewKanban` : "Vue Kanban"
- `myTasks.viewList` : "Vue Liste"
- `myTasks.allProjects` : "Tous les projets"
- `myTasks.allGroups` : "Tous les groupes"
- `myTasks.allTypes` : "Tous les types"
- `myTasks.allPriorities` : "Toutes les priorités"
- `myTasks.allEfforts` : "Tous les efforts"
- `myTasks.allAssignees` : "Tous"
- `myTasks.noTasks` : "Aucune tâche"
- `myTasks.backlog` : "Backlog"
- Sidebar : `sidebar.myTasks` : "Mes tâches"
## Hors périmètre
- Drag-and-drop entre colonnes (pas pertinent cross-projet)
- Affichage des temps/durées sur les tâches
- Pagination (à ajouter plus tard si nécessaire)
- Création de tâche depuis cette page (utiliser la vue projet pour ça)
- Recherche texte (pourra être ajoutée plus tard)

View 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)
```

View 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)

View 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.

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

View File

@@ -0,0 +1,103 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('gitea.settings.title') }}</h2>
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.url"
:label="$t('gitea.settings.url')"
:placeholder="$t('gitea.settings.urlPlaceholder')"
input-class="w-full"
/>
<div>
<MalioInputText
v-model="form.token"
:label="$t('gitea.settings.token')"
:placeholder="$t('gitea.settings.tokenPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
{{ $t('gitea.settings.tokenConfigured') }}
</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('gitea.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
:disabled="isTesting"
@click="handleTest"
>
{{ $t('gitea.settings.testConnection') }}
</button>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('gitea.settings.testSuccess') : $t('gitea.settings.testFailed') }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useGiteaService } from '~/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService()
const form = reactive({
url: '',
token: '',
})
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,
token: form.token || null,
})
hasToken.value = result.hasToken
form.token = ''
testResult.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
try {
const result = await testConnection()
testResult.value = result.success
} catch {
testResult.value = false
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>

View File

@@ -33,6 +33,26 @@
<ColorPicker v-model="form.color" />
</div>
<div v-if="giteaRepos.length" class="mt-4">
<MalioSelect
v-model="form.giteaRepoFullName"
:options="giteaRepoOptions"
label="Dépôt Gitea"
empty-option-label="Aucun dépôt"
min-width="w-full"
/>
</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"
@@ -43,13 +63,28 @@
</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>
<script setup lang="ts">
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
@@ -70,12 +105,28 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])
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: '',
description: '',
color: '#222783',
clientId: null as number | null,
giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null,
})
const touched = reactive({
@@ -95,12 +146,18 @@ watch(() => props.modelValue, (open) => {
form.description = props.project.description ?? ''
form.color = props.project.color ?? '#222783'
form.clientId = props.project.client?.id ?? null
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 = ''
form.description = ''
form.color = '#222783'
form.clientId = null
form.giteaRepoFullName = null
form.bookstackShelfId = null
}
touched.code = false
touched.name = false
@@ -124,6 +181,24 @@ async function handleSubmit() {
client: form.clientId ? `/api/clients/${form.clientId}` : null,
}
if (form.giteaRepoFullName) {
const [owner, repo] = form.giteaRepoFullName.split('/')
payload.giteaOwner = owner
payload.giteaRepo = repo
} else {
payload.giteaOwner = null
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 {
@@ -137,4 +212,32 @@ async function handleSubmit() {
isSubmitting.value = false
}
}
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>

View File

@@ -62,6 +62,7 @@
v-model="drawerOpen"
:group="selectedItem"
:project-id="projectId"
:tasks="[...activeTasks, ...archivedTasks]"
@saved="onSaved"
/>
</div>

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

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

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

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

View File

@@ -0,0 +1,425 @@
<template>
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
<!-- Header with tabs -->
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
<div class="flex gap-1">
<button
type="button"
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
:class="activeTab === 'branches'
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = 'branches'"
>
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
{{ $t('gitea.branch.title') }}
<span
v-if="branches.length"
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
>{{ branches.length }}</span>
</button>
<button
type="button"
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
:class="activeTab === 'prs'
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = 'prs'"
>
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
{{ $t('gitea.pr.title') }}
<span
v-if="pullRequests.length"
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
>{{ pullRequests.length }}</span>
</button>
</div>
<!-- Actions -->
<div class="flex gap-1">
<button
v-if="activeTab === 'branches'"
type="button"
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
:title="$t('gitea.branch.copy')"
@click="handleCopy"
>
<Icon name="mdi:content-copy" size="14" />
</button>
<button
v-if="activeTab === 'branches'"
type="button"
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
@click="showCreateForm = !showCreateForm"
>
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
{{ $t('gitea.branch.create') }}
</button>
</div>
</div>
<!-- Error state -->
<div v-if="error" class="px-4 py-3">
<p class="text-xs text-red-500">{{ error }}</p>
</div>
<!-- Create branch form (inline) -->
<Transition name="slide-down">
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
<MalioSelect
v-model="branchForm.type"
:options="typeOptions"
:label="$t('gitea.branch.type')"
min-width="w-full"
/>
<MalioInputText
v-model="branchForm.baseBranch"
:label="$t('gitea.branch.baseBranch')"
input-class="w-full"
/>
<button
type="button"
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
:disabled="isCreating"
@click="handleCreate"
>
{{ isCreating ? '...' : $t('gitea.branch.create') }}
</button>
</div>
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
{{ branchPreview }}
</code>
</div>
</Transition>
<!-- Content area with scroll -->
<div class="max-h-64 overflow-y-auto overscroll-contain">
<!-- Loading -->
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
</div>
<!-- BRANCHES TAB -->
<template v-if="activeTab === 'branches' && !isLoading">
<div v-if="branches.length" class="divide-y divide-neutral-100">
<div
v-for="branch in branches"
:key="branch.name"
class="group"
>
<!-- Branch header (clickable to expand) -->
<button
type="button"
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
@click="toggleBranch(branch.name)"
>
<Icon
name="mdi:chevron-right"
size="14"
class="shrink-0 text-neutral-400 transition-transform"
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
/>
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
{{ branch.name }}
</span>
<span
v-if="branch.commits.length"
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
>
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
</span>
<a
:href="branchUrl(branch.name)"
target="_blank"
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
@click.stop
>
<Icon name="mdi:open-in-new" size="12" />
</a>
</button>
<!-- Commits (collapsible) -->
<Transition name="expand">
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
<div
v-for="(commit, idx) in branch.commits.slice(0, 10)"
:key="commit.sha"
class="flex items-center gap-2 px-4 py-1.5"
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
>
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
</div>
<div
v-if="branch.commits.length > 10"
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
>
+{{ branch.commits.length - 10 }} commits
</div>
</div>
</Transition>
</div>
</div>
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
{{ $t('gitea.branch.noBranches') }}
</p>
</template>
<!-- PULL REQUESTS TAB -->
<template v-if="activeTab === 'prs' && !isLoadingPrs">
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
<div
v-for="pr in pullRequests"
:key="pr.number"
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
>
<!-- Status pill -->
<span
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
:class="prStatusClass(pr)"
>
{{ prStatusLabel(pr) }}
</span>
<!-- PR content -->
<div class="min-w-0 flex-1">
<a
:href="pr.url"
target="_blank"
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
>
<span class="text-neutral-400">#{{ pr.number }}</span>
{{ pr.title }}
</a>
<div class="mt-1 flex items-center gap-2">
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
{{ pr.headBranch }}
</span>
<!-- CI statuses -->
<template v-if="pr.ciStatuses.length">
<a
v-for="ci in pr.ciStatuses"
:key="ci.context"
:href="ci.target_url"
target="_blank"
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
:class="ciStatusClass(ci.status)"
>
<Icon :name="ciStatusIcon(ci.status)" size="10" />
{{ ci.context }}
</a>
</template>
</div>
</div>
</div>
</div>
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
{{ $t('gitea.pr.noPrs') }}
</p>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'
const { t } = useI18n()
const props = defineProps<{
task: Task
giteaUrl: string
}>()
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
const activeTab = ref<'branches' | 'prs'>('branches')
const branches = ref<GiteaBranch[]>([])
const pullRequests = ref<GiteaPullRequest[]>([])
const isLoading = ref(true)
const isLoadingPrs = ref(true)
const isCreating = ref(false)
const error = ref('')
const showCreateForm = ref(false)
const expandedBranches = ref(new Set<string>())
const branchForm = reactive({
type: 'feature',
baseBranch: 'develop',
})
const typeOptions = [
{ label: t('gitea.branch.types.feature'), value: 'feature' },
{ label: t('gitea.branch.types.fix'), value: 'fix' },
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
{ label: t('gitea.branch.types.chore'), value: 'chore' },
]
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
const branchPreview = computed(() => {
if (!props.task.project?.code || !props.task.number) return ''
const slug = props.task.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50)
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
})
function toggleBranch(name: string) {
if (expandedBranches.value.has(name)) {
expandedBranches.value.delete(name)
} else {
expandedBranches.value.add(name)
}
}
function branchUrl(name: string): string {
const project = props.task.project
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
}
function commitFirstLine(message: string): string {
return message.split('\n')[0]
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return "aujourd'hui"
if (diffDays === 1) return 'hier'
if (diffDays < 7) return `il y a ${diffDays}j`
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
function prStatusClass(pr: GiteaPullRequest): string {
if (pr.merged) return 'bg-purple-500'
if (pr.state === 'open') return 'bg-green-500'
return 'bg-red-500'
}
function prStatusLabel(pr: GiteaPullRequest): string {
if (pr.merged) return t('gitea.pr.merged')
if (pr.state === 'open') return t('gitea.pr.open')
return t('gitea.pr.closed')
}
function ciStatusClass(status: string): string {
if (status === 'success') return 'bg-green-100 text-green-700'
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
return 'bg-yellow-100 text-yellow-700'
}
function ciStatusIcon(status: string): string {
if (status === 'success') return 'mdi:check-circle'
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
return 'mdi:clock-outline'
}
async function loadData() {
if (!props.task.id) return
isLoading.value = true
isLoadingPrs.value = true
error.value = ''
try {
branches.value = await listBranches(props.task.id)
// Auto-expand first branch
if (branches.value.length === 1) {
expandedBranches.value.add(branches.value[0].name)
}
} catch (e: any) {
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
} finally {
isLoading.value = false
}
try {
pullRequests.value = await listPullRequests(props.task.id)
} catch {
// PR errors don't block branch display
} finally {
isLoadingPrs.value = false
}
}
async function handleCreate() {
isCreating.value = true
try {
await createBranch(props.task.id, {
type: branchForm.type,
baseBranch: branchForm.baseBranch,
})
showCreateForm.value = false
await loadData()
} finally {
isCreating.value = false
}
}
async function handleCopy() {
try {
const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name)
const { success } = useToast()
success(t('gitea.branch.copied'))
} catch {
// Silently fail
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.slide-down-enter-active {
transition: opacity 0.15s ease;
}
.slide-down-leave-active {
transition: opacity 0.1s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
}
.expand-enter-active,
.expand-leave-active {
transition: all 0.15s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
.expand-enter-to,
.expand-leave-from {
max-height: 500px;
opacity: 1;
}
</style>

View File

@@ -17,7 +17,32 @@
<ColorPicker v-model="form.color" />
</div>
<div class="mt-6 flex justify-end">
<div
v-if="isEditing && !canArchive && !canUnarchive && nonFinalTasksCount > 0"
class="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700"
>
{{ $t('archive.groupNonFinalTasks', { count: nonFinalTasksCount }) }}
</div>
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
v-if="canArchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
@@ -32,12 +57,15 @@
<script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
group: TaskGroup | null
projectId: number
tasks?: Task[]
}>()
const emit = defineEmits<{
@@ -79,6 +107,51 @@ watch(() => props.modelValue, (open) => {
})
const { create, update } = useTaskGroupService()
const taskService = useTaskService()
const groupTasks = computed(() =>
(props.tasks ?? []).filter(t => t.group?.id === props.group?.id)
)
const nonFinalTasksCount = computed(() =>
groupTasks.value.filter(t => t.status?.isFinal !== true).length
)
const canArchive = computed(() => {
if (!isEditing.value || !props.group || props.group.archived) return false
if (groupTasks.value.length === 0) return false
return nonFinalTasksCount.value === 0
})
const canUnarchive = computed(() => {
return isEditing.value && !!props.group?.archived
})
async function handleArchive() {
if (!props.group) return
isSubmitting.value = true
try {
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: true })))
await update(props.group.id, { archived: true })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleUnarchive() {
if (!props.group) return
isSubmitting.value = true
try {
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: false })))
await update(props.group.id, { archived: false })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleSubmit() {
touched.title = true

View File

@@ -0,0 +1,549 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="task-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<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
v-if="isEditing && task?.project?.code && task?.number"
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
>
{{ task.project.code }}-{{ task.number }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
</h2>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Title -->
<MalioInputText
v-model="form.title"
label="Titre"
input-class="w-full"
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true"
/>
<!-- Two-column selects -->
<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"
label="Statut"
empty-option-label="Aucun statut"
min-width="w-full"
/>
<MalioSelect
v-model="form.assigneeId"
:options="userOptions"
label="User"
empty-option-label="Aucun utilisateur"
min-width="w-full"
/>
<MalioSelect
v-model="form.effortId"
:options="effortOptions"
label="Effort"
empty-option-label="Aucun effort"
min-width="w-full"
/>
<MalioSelect
v-model="form.priorityId"
:options="priorityOptions"
label="Priorité"
empty-option-label="Aucune priorité"
min-width="w-full"
/>
<MalioSelect
v-model="form.groupId"
:options="groupOptions"
label="Groupe"
empty-option-label="Aucun groupe"
min-width="w-full"
/>
</div>
<!-- Tags -->
<div v-if="tags.length" class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
<div class="flex flex-wrap gap-2">
<label
v-for="tag in tags"
:key="tag.id"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.tagIds.includes(tag.id)
? 'text-white shadow-sm'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
>
<input
type="checkbox"
class="hidden"
:value="tag.id"
:checked="form.tagIds.includes(tag.id)"
@change="toggleTag(tag.id)"
/>
{{ tag.label }}
</label>
</div>
</div>
<!-- Description -->
<div class="mt-5">
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
/>
</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"
:task="task"
: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"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
<button
v-if="isEditing"
type="button"
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
Supprimer
</button>
<div class="flex gap-3">
<button
v-if="canArchive"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="close"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</div>
</form>
<ConfirmDeleteTaskModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
<!-- Confirm delete document modal -->
<ConfirmDeleteDocumentModal
v-model="confirmDeleteDocOpen"
@confirm="confirmDeleteDocument"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<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'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
task: Task | null
projectId: number
statuses: TaskStatus[]
efforts: TaskEffort[]
priorities: TaskPriority[]
tags: TaskTag[]
groups: TaskGroup[]
users: UserData[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isOpen.value = false
}
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
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: '',
statusId: null as number | null,
effortId: null as number | null,
priorityId: null as number | null,
assigneeId: null as number | null,
groupId: null as number | null,
tagIds: [] as number[],
})
const touched = reactive({
title: false,
})
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
)
const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id }))
)
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const groupOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id }))
)
const canArchive = computed(() => {
if (!isEditing.value || !props.task) return false
if (props.task.archived) return false
const status = props.statuses.find(s => s.id === props.task?.status?.id)
return !!status?.isFinal
})
const canUnarchive = computed(() => {
return isEditing.value && !!props.task?.archived
})
function toggleTag(id: number) {
const idx = form.tagIds.indexOf(id)
if (idx >= 0) {
form.tagIds.splice(idx, 1)
} else {
form.tagIds.push(id)
}
}
function populateForm(task: Task | null) {
if (task) {
form.title = task.title ?? ''
form.description = task.description ?? ''
form.statusId = task.status?.id ?? null
form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
} else {
form.title = ''
form.description = ''
form.statusId = null
form.effortId = null
form.priorityId = null
form.assigneeId = null
form.groupId = null
form.tagIds = []
}
touched.title = false
}
watch(() => props.modelValue, (open) => {
if (open) {
populateForm(props.task)
}
})
watch(() => props.task, (task) => {
if (props.modelValue) {
populateForm(task)
}
})
watch(() => props.modelValue, async (open) => {
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
})
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
isSubmitting.value = true
try {
await remove(props.task.id)
confirmDeleteOpen.value = false
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleArchive() {
if (!props.task) return
const timerStore = useTimerStore()
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
}
isSubmitting.value = true
try {
await update(props.task.id, { archived: true })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleUnarchive() {
if (!props.task) return
isSubmitting.value = true
try {
await update(props.task.id, { archived: false })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
isSubmitting.value = true
try {
const payload: TaskWrite = {
title: form.title.trim(),
description: form.description.trim() || null,
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
if (isEditing.value && props.task) {
await update(props.task.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.task-modal-enter-active,
.task-modal-leave-active {
transition: opacity 0.2s ease;
}
.task-modal-enter-active > div:last-child,
.task-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.task-modal-enter-from,
.task-modal-leave-to {
opacity: 0;
}
.task-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.task-modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -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
@@ -108,6 +107,18 @@
</div>
</div>
<!-- Current time indicator -->
<div
v-if="isToday(day.date)"
class="absolute left-0 right-0 z-10 pointer-events-none"
:style="{ top: `${currentTimeTopPx}px` }"
>
<div class="relative flex items-center">
<div class="absolute -left-[5px] h-[10px] w-[10px] rounded-full bg-orange-500" />
<div class="h-[2px] w-full bg-orange-500" />
</div>
</div>
<!-- Drag ghost preview -->
<div
v-if="dragState && dragState.targetDayIndex === dayIndex"
@@ -154,6 +165,27 @@ const gridBodyEl = ref<HTMLElement | null>(null)
const dayColumnEls = ref<HTMLElement[]>([])
const stickyOffset = computed(() => props.stickyOffset ?? 0)
// --- Current time indicator ---
const nowMinutes = ref(0)
let nowTimer: ReturnType<typeof setInterval> | undefined
function updateNowMinutes() {
const now = new Date()
nowMinutes.value = now.getHours() * 60 + now.getMinutes()
}
const currentTimeTopPx = computed(() => (nowMinutes.value / 60) * hourHeight)
updateNowMinutes()
onMounted(() => {
nowTimer = setInterval(updateNowMinutes, 60_000)
})
onUnmounted(() => {
clearInterval(nowTimer)
})
function getScrollParent(): HTMLElement | null {
let el = calendarEl.value?.parentElement
while (el) {

View File

@@ -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()

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

View File

@@ -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" />

View File

@@ -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 }}

View File

@@ -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.",
@@ -83,6 +98,142 @@
"showArchived": "Voir les groupes archivés",
"hideArchived": "Masquer les groupes archivés",
"statusFinal": "Statut final",
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe."
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe.",
"groupNonFinalTasks": "Il reste {count} ticket(s) sans statut final dans ce groupe."
},
"myTasks": {
"title": "Mes tâches",
"viewKanban": "Vue Kanban",
"viewList": "Vue Liste",
"allProjects": "Tous les projets",
"allGroups": "Tous les groupes",
"allTypes": "Tous les types",
"allPriorities": "Toutes les priorités",
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"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"
},
"common": {
"cancel": "Annuler",
"loading": "Chargement..."
},
"gitea": {
"settings": {
"title": "Configuration Gitea",
"url": "URL du serveur",
"urlPlaceholder": "https://git.example.com",
"token": "Token API",
"tokenPlaceholder": "Entrez un nouveau token",
"tokenConfigured": "Token configuré",
"save": "Enregistrer",
"saved": "Configuration Gitea sauvegardée.",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie.",
"testFailed": "Connexion échouée."
},
"branch": {
"title": "Git",
"create": "Créer une branche",
"created": "Branche créée avec succès.",
"copy": "Copier le nom",
"copied": "Nom de branche copié.",
"type": "Type",
"baseBranch": "Branche de base",
"preview": "Aperçu",
"types": {
"feature": "feature",
"fix": "fix",
"refactor": "refactor",
"hotfix": "hotfix",
"chore": "chore"
},
"noBranches": "Aucune branche liée.",
"commits": "Commits",
"noCommits": "Aucun commit."
},
"pr": {
"title": "Pull Requests",
"noPrs": "Aucune pull request.",
"open": "Ouverte",
"merged": "Mergée",
"closed": "Fermée",
"ci": "CI/CD"
},
"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é"
}
}
}

View File

@@ -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,43 +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="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>
@@ -62,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()"
>
@@ -93,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>
@@ -123,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
@@ -205,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>

View File

@@ -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"
},

View File

@@ -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"
}
}

View File

@@ -1,30 +1,34 @@
<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'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
</div>
</div>
</template>
@@ -39,6 +43,8 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
] as const
type TabKey = typeof tabs[number]['key']

View File

@@ -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>

444
frontend/pages/my-tasks.vue Normal file
View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('myTasks.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
const groupService = useTaskGroupService()
const userService = useUserService()
const projectService = useProjectService()
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const isLoading = ref(true)
// Filters
const selectedProjectId = ref<number | null>(null)
const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
// Timer
const timerStore = useTimerStore()
function isTimerOnTask(task: Task): boolean {
const entry = timerStore.activeEntry
if (!entry?.task) return false
const entryTaskId = typeof entry.task === 'string'
? entry.task
: (entry.task['@id'] ?? entry.task.id)
const taskId = task['@id'] ?? task.id
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
}
// Filter options
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const groupOptions = computed(() => {
let g = groups.value.filter(g => !g.archived)
if (selectedProjectId.value) {
g = g.filter(g => g.project?.id === selectedProjectId.value)
}
return g.map(g => ({ label: g.title, value: g.id }))
})
const tagOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
const priorityOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id }))
)
const effortOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const assigneeOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
}
const backlogTasks = computed(() =>
tasks.value.filter(t => !t.status)
)
// Data loading
async function loadReferenceData() {
const [s, e, pr, tg, g, u, p] = await Promise.all([
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
groupService.getAll(),
userService.getAll(),
projectService.getAll(),
])
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = tg
groups.value = g
users.value = u
projects.value = p
}
async function loadTasks() {
const params: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (selectedAssigneeId.value) {
params.assignee = `/api/users/${selectedAssigneeId.value}`
}
if (selectedProjectId.value) {
params.project = `/api/projects/${selectedProjectId.value}`
}
if (selectedGroupId.value) {
params.group = `/api/task_groups/${selectedGroupId.value}`
}
if (selectedPriorityId.value) {
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
}
if (selectedEffortId.value) {
params.effort = `/api/task_efforts/${selectedEffortId.value}`
}
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
tasks.value = await taskService.getFiltered(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks()])
} finally {
isLoading.value = false
}
}
// Watch filters to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
() => { loadTasks() },
)
// Reset group when project changes
watch(selectedProjectId, () => {
selectedGroupId.value = null
}, { flush: 'sync' })
// Drag & drop
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
function onDragEnter(id: number) {
dragCounter.value++
dragOverStatusId.value = id
}
function onDragLeave() {
dragCounter.value--
if (dragCounter.value === 0) {
dragOverStatusId.value = null
}
}
function onDrop(event: DragEvent) {
dragCounter.value = 0
dragOverStatusId.value = null
return Number(event.dataTransfer!.getData('text/plain'))
}
async function onDropStatus(event: DragEvent, status: TaskStatus) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || task.status?.id === status.id) return
task.status = status
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
}
async function onDropBacklog(event: DragEvent) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || !task.status) return
task.status = null
await taskService.update(taskId, { status: null })
}
// Modal
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
}
async function onSaved() {
await loadTasks()
}
onMounted(() => {
loadAll()
})
</script>
<template>
<div>
<!-- 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>
<!-- Kanban View -->
<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
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)"
>
<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"
:task="task"
@click="openTaskEdit(task)"
/>
</div>
<p
v-if="backlogTasks.length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<div
v-for="task in tasks"
:key="task.id"
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 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"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<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>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
<!-- TaskModal -->
<TaskModal
v-model="taskModalOpen"
:task="selectedTask"
:project-id="selectedTask?.project?.id ?? 0"
:statuses="statuses"
:efforts="efforts"
:priorities="priorities"
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
@saved="onSaved"
/>
</div>
</template>

View File

@@ -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>
@@ -56,7 +58,7 @@
</div>
</div>
<TaskDrawer
<TaskModal
v-model="taskDrawerOpen"
:task="selectedTask"
:project-id="projectId"

View File

@@ -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>

View File

@@ -1,23 +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">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="w-64"
/>
<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 -->
@@ -64,59 +96,18 @@
@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>
<TaskDrawer
<TaskModal
v-model="taskDrawerOpen"
:task="selectedTask"
:project-id="projectId"
@@ -175,6 +166,9 @@ const users = ref<UserData[]>([])
const isLoading = ref(true)
const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(null)
const selectedStatusId = ref<number | null>(null)
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
@@ -184,11 +178,32 @@ const groupFilterOptions = computed(() =>
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
)
const tagFilterOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
const userFilterOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
const statusFilterOptions = computed(() =>
statuses.value.map(s => ({ label: s.label, value: s.id }))
)
const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) {
result = result.filter(t => t.group?.id === selectedGroupId.value)
}
if (selectedTagId.value) {
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
}
if (selectedAssigneeId.value) {
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
}
if (selectedStatusId.value) {
result = result.filter(t => t.status?.id === selectedStatusId.value)
}
return result
})
@@ -254,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)

View File

@@ -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

View File

@@ -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"

View 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,
)
})

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

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

View File

@@ -0,0 +1,57 @@
export type GiteaSettings = {
url: string | null
hasToken: boolean
}
export type GiteaSettingsWrite = {
url: string | null
token: string | null
}
export type GiteaRepository = {
fullName: string
name: string
owner: string
}
export type GiteaBranch = {
name: string
commits: GiteaCommit[]
}
export type GiteaCommit = {
sha: string
message: string
author: string
date: string
}
export type GiteaBranchCreate = {
type: string
baseBranch: string
}
export type GiteaPullRequest = {
number: number
title: string
state: string
merged: boolean
headBranch: string
author: string
url: string
ciStatuses: GiteaCiStatus[]
}
export type GiteaCiStatus = {
context: string
status: string
target_url: string
}
export type GiteaBranchName = {
name: string
}
export type GiteaTestResult = {
success: boolean
}

View File

@@ -8,6 +8,11 @@ export type Project = {
description: string | null
color: string
client: Client | null
giteaOwner: string | null
giteaRepo: string | null
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean
}
export type ProjectWrite = {
@@ -16,4 +21,9 @@ export type ProjectWrite = {
description: string | null
color: string
client: string | null // IRI : "/api/clients/1" ou null
giteaOwner?: string | null
giteaRepo?: string | null
bookstackShelfId?: number | null
bookstackShelfName?: string | null
archived?: boolean
}

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

View File

@@ -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
}

View File

@@ -0,0 +1,66 @@
import type {
GiteaSettings,
GiteaSettingsWrite,
GiteaRepository,
GiteaBranch,
GiteaBranchCreate,
GiteaPullRequest,
GiteaBranchName,
GiteaTestResult,
} from './dto/gitea'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useGiteaService() {
const api = useApi()
async function getSettings(): Promise<GiteaSettings> {
return api.get<GiteaSettings>('/settings/gitea')
}
async function saveSettings(payload: GiteaSettingsWrite): Promise<GiteaSettings> {
return api.put<GiteaSettings>('/settings/gitea', payload as Record<string, unknown>, {
toastSuccessKey: 'gitea.settings.saved',
})
}
async function testConnection(): Promise<GiteaTestResult> {
return api.post<GiteaTestResult>('/settings/gitea/test')
}
async function listRepositories(): Promise<GiteaRepository[]> {
const data = await api.get<HydraCollection<GiteaRepository>>('/gitea/repositories')
return extractHydraMembers(data)
}
async function listBranches(taskId: number): Promise<GiteaBranch[]> {
const data = await api.get<HydraCollection<GiteaBranch>>(`/tasks/${taskId}/gitea/branches`)
return extractHydraMembers(data)
}
async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise<GiteaBranch> {
return api.post<GiteaBranch>(`/tasks/${taskId}/gitea/branches`, payload as Record<string, unknown>, {
toastSuccessKey: 'gitea.branch.created',
})
}
async function listPullRequests(taskId: number): Promise<GiteaPullRequest[]> {
const data = await api.get<HydraCollection<GiteaPullRequest>>(`/tasks/${taskId}/gitea/pull-requests`)
return extractHydraMembers(data)
}
async function getBranchName(taskId: number, type: string): Promise<GiteaBranchName> {
return api.get<GiteaBranchName>(`/tasks/${taskId}/gitea/branch-name/${type}`)
}
return {
getSettings,
saveSettings,
testConnection,
listRepositories,
listBranches,
createBranch,
listPullRequests,
getBranchName,
}
}

View File

@@ -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',
})
}

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

View File

@@ -26,6 +26,11 @@ export function useTaskService() {
return extractHydraMembers(data)
}
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: TaskWrite): Promise<Task> {
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
toastSuccessKey: 'tasks.created',
@@ -44,5 +49,5 @@ export function useTaskService() {
})
}
return { getAll, getByProject, getByProjectArchived, create, update, remove }
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
}

View File

@@ -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 }
})

View File

@@ -0,0 +1,41 @@
<?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 Version20260313125531 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 gitea_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, url VARCHAR(255) DEFAULT NULL, encrypted_token TEXT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('ALTER TABLE project ADD gitea_owner VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD gitea_repo VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE task ALTER archived DROP DEFAULT');
$this->addSql('ALTER TABLE task_group ALTER archived DROP DEFAULT');
$this->addSql('ALTER TABLE task_status ALTER is_final DROP DEFAULT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE gitea_configuration');
$this->addSql('ALTER TABLE project DROP gitea_owner');
$this->addSql('ALTER TABLE project DROP gitea_repo');
$this->addSql('ALTER TABLE task ALTER archived SET DEFAULT false');
$this->addSql('ALTER TABLE task_group ALTER archived SET DEFAULT false');
$this->addSql('ALTER TABLE task_status ALTER is_final SET DEFAULT false');
}
}

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\State\GiteaBranchProcessor;
use App\State\GiteaBranchProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
),
new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches',
denormalizationContext: ['groups' => ['gitea_branch:write']],
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class,
),
],
)]
final class GiteaBranch
{
#[Groups(['gitea_branch:read'])]
public string $name = '';
#[Groups(['gitea_branch:write'])]
public string $type = 'feature';
#[Groups(['gitea_branch:write'])]
public string $baseBranch = 'main';
/**
* @var array<array{sha: string, message: string, author: string, date: string}>
*/
#[Groups(['gitea_branch:read'])]
public array $commits = [];
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\GiteaBranchNameProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class,
),
],
)]
final class GiteaBranchName
{
#[Groups(['gitea_branch_name:read'])]
public string $name = '';
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\GiteaPullRequestProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class,
),
],
)]
final class GiteaPullRequest
{
#[Groups(['gitea_pr:read'])]
public int $number = 0;
#[Groups(['gitea_pr:read'])]
public string $title = '';
#[Groups(['gitea_pr:read'])]
public string $state = '';
#[Groups(['gitea_pr:read'])]
public bool $merged = false;
#[Groups(['gitea_pr:read'])]
public string $headBranch = '';
#[Groups(['gitea_pr:read'])]
public string $author = '';
#[Groups(['gitea_pr:read'])]
public string $url = '';
/**
* @var array<array{context: string, status: string, target_url: string}>
*/
#[Groups(['gitea_pr:read'])]
public array $ciStatuses = [];
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\GiteaRepositoryProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/gitea/repositories',
normalizationContext: ['groups' => ['gitea_repo:read']],
provider: GiteaRepositoryProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaRepository
{
#[Groups(['gitea_repo:read'])]
public string $fullName = '';
#[Groups(['gitea_repo:read'])]
public string $name = '';
#[Groups(['gitea_repo:read'])]
public string $owner = '';
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\GiteaSettingsProcessor;
use App\State\GiteaSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/gitea',
normalizationContext: ['groups' => ['gitea_settings:read']],
provider: GiteaSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/gitea',
denormalizationContext: ['groups' => ['gitea_settings:write']],
normalizationContext: ['groups' => ['gitea_settings:read']],
provider: GiteaSettingsProvider::class,
processor: GiteaSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaSettings
{
#[Groups(['gitea_settings:read', 'gitea_settings:write'])]
public ?string $url = null;
#[Groups(['gitea_settings:write'])]
public ?string $token = null;
#[Groups(['gitea_settings:read'])]
public bool $hasToken = false;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\GiteaTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/gitea/test',
input: false,
normalizationContext: ['groups' => ['gitea_test:read']],
provider: GiteaTestConnectionProvider::class,
processor: GiteaTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class GiteaTestConnection
{
#[Groups(['gitea_test:read'])]
public bool $success = false;
}

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

View File

@@ -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'));

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

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\GiteaConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
class GiteaConfiguration
{
#[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 $encryptedToken = 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 getEncryptedToken(): ?string
{
return $this->encryptedToken;
}
public function setEncryptedToken(?string $encryptedToken): static
{
$this->encryptedToken = $encryptedToken;
return $this;
}
public function hasToken(): bool
{
return null !== $this->encryptedToken;
}
}

View File

@@ -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
@@ -38,7 +41,7 @@ class Project
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read', 'time_entry:read'])]
#[Groups(['project:read', 'time_entry:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(length: 10, unique: true)]
@@ -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')]
@@ -64,6 +67,26 @@ class Project
#[Groups(['project:read', 'project:write'])]
private ?Client $client = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['project:read', 'project:write', 'task:read'])]
private ?string $giteaOwner = null;
#[ORM\Column(length: 255, nullable: true)]
#[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;
@@ -128,4 +151,69 @@ class Project
return $this;
}
public function getGiteaOwner(): ?string
{
return $this->giteaOwner;
}
public function setGiteaOwner(?string $giteaOwner): static
{
$this->giteaOwner = $giteaOwner;
return $this;
}
public function getGiteaRepo(): ?string
{
return $this->giteaRepo;
}
public function setGiteaRepo(?string $giteaRepo): static
{
$this->giteaRepo = $giteaRepo;
return $this;
}
public function hasGiteaRepo(): bool
{
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;
}
}

View File

@@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new GetCollection(paginationEnabled: false),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')"),
@@ -32,7 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['task:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
@@ -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;
}
}

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

View File

@@ -92,7 +92,7 @@ class TaskStatus
return $this;
}
public function isFinal(): bool
public function getIsFinal(): bool
{
return $this->isFinal;
}

View File

@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_type:read']],
denormalizationContext: ['groups' => ['task_type:write']],
order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskTypeRepository::class)]
class TaskType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_type:read', 'task:read', 'time_entry:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
private ?string $color = '#222783';
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
}

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

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

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
use Throwable;
final class GiteaApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View 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([]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\GiteaConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class GiteaConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GiteaConfiguration::class);
}
public function findSingleton(): ?GiteaConfiguration
{
return $this->findOneBy([]);
}
}

View 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']);
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskType::class);
}
}

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

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\GiteaConfiguration;
use App\Entity\Project;
use App\Entity\Task;
use App\Exception\GiteaApiException;
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
{
private SluggerInterface $slugger;
public function __construct(
private HttpClientInterface $httpClient,
private GiteaConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {
$this->slugger = new AsciiSlugger('fr');
}
public function testConnection(): bool
{
try {
$this->request('GET', '/api/v1/version');
return true;
} catch (GiteaApiException) {
return false;
}
}
/**
* @return array<array{full_name: string, name: string, owner: array{login: string}}>
*/
public function listRepositories(): array
{
$result = [];
$page = 1;
do {
$data = $this->request('GET', '/api/v1/repos/search', [
'query' => ['page' => $page, 'limit' => 50],
]);
$result = array_merge($result, $data['data'] ?? []);
++$page;
} while (!empty($data['data']) && 50 === count($data['data']));
return $result;
}
public function getDefaultBranch(Project $project): string
{
$this->assertProjectHasRepo($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
));
return $data['default_branch'] ?? 'main';
}
public function createBranch(Project $project, Task $task, string $type, string $baseBranch): string
{
$this->assertProjectHasRepo($project);
$branchName = $this->generateBranchName($task, $type);
$this->request('POST', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'json' => [
'new_branch_name' => $branchName,
'old_branch_name' => $baseBranch,
],
]);
return $branchName;
}
public function generateBranchName(Task $task, string $type): string
{
$project = $task->getProject();
if (null === $project) {
throw new GiteaApiException('Task has no project.');
}
$slug = $this->slugger->slug($task->getTitle())->lower()->truncate(50)->toString();
$slug = rtrim($slug, '-');
return sprintf('%s/%s-%d-%s', $type, $project->getCode(), $task->getNumber(), $slug);
}
/**
* @return array<array{name: string, commit: array}>
*/
public function listBranches(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$allBranches = [];
$page = 1;
do {
$pageBranches = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['page' => $page, 'limit' => 50],
]);
$allBranches = array_merge($allBranches, $pageBranches);
++$page;
} while (!empty($pageBranches) && 50 === count($pageBranches));
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool {
return 1 === preg_match($regex, $branch['name']);
}));
}
/**
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
*/
public function listBranchCommits(Project $project, string $branch): array
{
$this->assertProjectHasRepo($project);
$defaultBranch = $this->getDefaultBranch($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/compare/%s...%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$defaultBranch,
urlencode($branch),
));
return $data['commits'] ?? [];
}
/**
* @return array<array{number: int, title: string, state: string, head: array, user: array, merged: bool}>
*/
public function listPullRequests(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$branches = $this->listBranches($project, $taskCode);
$prs = [];
foreach ($branches as $branch) {
$branchPrs = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/pulls',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['state' => 'all', 'head' => $branch['name']],
]);
$prs = array_merge($prs, $branchPrs);
}
// Fetch CI status for each PR
foreach ($prs as &$pr) {
$sha = $pr['head']['sha'] ?? null;
if (null !== $sha) {
try {
$pr['ci_statuses'] = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/commits/%s/statuses',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$sha,
));
} catch (GiteaApiException) {
$pr['ci_statuses'] = [];
}
}
}
return $prs;
}
private function getConfiguration(): GiteaConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw new GiteaApiException('Gitea is not configured.');
}
return $config;
}
private function getDecryptedToken(GiteaConfiguration $config): string
{
$encrypted = $config->getEncryptedToken();
if (null === $encrypted) {
throw new GiteaApiException('Gitea token is not set.');
}
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
{
if (!$project->hasGiteaRepo()) {
throw new GiteaApiException('Project has no Gitea repository configured.');
}
}
/**
* @param array<string, mixed> $options
*/
private function request(string $method, string $path, array $options = []): array
{
$config = $this->getConfiguration();
$token = $this->getDecryptedToken($config);
$options['headers'] = array_merge($options['headers'] ?? [], [
'Authorization' => 'token '.$token,
'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->extractGiteaError($e);
throw new GiteaApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) {
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Service;
use RuntimeException;
use SodiumException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class TokenEncryptor
{
private readonly string $key;
private readonly bool $configured;
public function __construct(
#[Autowire('%env(ENCRYPTION_KEY)%')]
string $encryptionKey,
) {
if ('' === $encryptionKey) {
$this->key = '';
$this->configured = false;
return;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
$this->key = '';
$this->configured = false;
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);
return sodium_bin2hex($nonce.$ciphertext);
}
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');
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
if (false === $plaintext) {
throw new RuntimeException('Failed to decrypt token.');
}
return $plaintext;
}
private function assertConfigured(): void
{
if (!$this->configured) {
throw new RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.');
}
}
}

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

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

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