Compare commits

...

61 Commits

Author SHA1 Message Date
gitea-actions
32aff3d4d3 chore: bump version to v0.3.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 2m6s
2026-03-24 20:06:10 +00:00
Matthieu
9760de1805 feat : add export button to time-tracking page
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:16:06 +01:00
Matthieu
f72dd57bd0 feat : add getExportUrl to time-entries service and i18n key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:15:04 +01:00
Matthieu
a8f7c77758 feat : add TimeEntryExportController with auth, validation, and filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:03:35 +01:00
Matthieu
a09a415393 feat : add TimeEntryExportService generating XLSX with detail and recap sheets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:02:18 +01:00
Matthieu
8208df1ade feat : add findForExport repository method for time entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:00:22 +01:00
Matthieu
15af8975f0 chore : add phpoffice/phpspreadsheet dependency for time entry export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:59:30 +01:00
Matthieu
040cbfc588 docs : add time entry export implementation plan (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:54:06 +01:00
Matthieu
e796741dd8 docs : add time entry export design spec (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:47:33 +01:00
Matthieu
9e7d196443 chore : bump version to v0.3.8
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:20:57 +01:00
Matthieu
3e9a0c93eb fix(admin) : embed client and project in user list serialization
Client.id/name and Project.id/name were missing the user:list group,
causing them to be serialized as IRI strings instead of embedded objects.
This broke the user edit form which expected object properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:20:17 +01:00
Matthieu
1d533d1d28 fix : allow ROLE_CLIENT to upload and view documents on client tickets
GetCollection/Get required ROLE_USER which ROLE_CLIENT doesn't have.
Added TaskDocumentProvider to scope client access to their own tickets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:17:48 +01:00
Matthieu
efa42b6039 chore : bump version to v0.3.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:12:10 +01:00
gitea-actions
7b0c2d9fba chore: bump version to v0.3.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m38s
2026-03-19 17:10:47 +00:00
Matthieu
4ce0214ec9 feat(ui) : add dark mode toggle and remove inline dark: classes
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
- Add dark mode toggle button in top nav
- Add darkMode store with localStorage persistence
- Enable Tailwind class-based dark mode
- Import dark.css global overrides
- Remove inline dark: Tailwind classes (handled by global CSS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
43304bebcc chore : update auto-generated reference config (Symfony rebuild)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
6668af73a7 chore : update MCP config with HTTP transport and local fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
ff9a6763c3 fix(ui) : add dark mode overrides for MalioSelect, forms, and date inputs
- Override floating-label background (hardcoded white in malio/layer-ui)
- Override text-black, border-black, border-m-muted for Malio components
- Add color-scheme: dark for native date/datetime inputs
- Override red/blue button backgrounds for dark mode
- Fix checkbox/radio borders in dark mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
db5b3d39f9 fix : detect isFinal transition using Doctrine UnitOfWork original entity data
The previous approach read $data->getStatus() which already had the NEW
status after API Platform deserialization, making wasAlreadyFinal always
true when transitioning to a final status. Now we read the original status
from UnitOfWork snapshot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
1fdc68c66d fix(ui) : remove invalid string props on MalioInputTextArea (expects Number)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
99b664cdd8 fix : use getIsFinal() instead of isFinal() on TaskStatus
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
fd1da75fd7 fix(ui) : use native date/datetime inputs instead of MalioInputText for planning dates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
66264e3b8c fix(ui) : escape @ in i18n placeholders for vue-i18n compatibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
a89fa6a7af docs : update CLAUDE.md with Zimbra calendar integration references
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
6862944726 feat : add Zimbra config and calendar task fixtures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
e00c33d20b feat(ui) : add Zimbra CalDAV configuration tab in admin page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
1aa72c3b56 feat(ui) : add deadline/scheduled columns and sort options to Mes tâches page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
6a8e406cc5 feat(ui) : add deadline badges and calendar/recurrence icons to task cards and list items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
83b42139b2 feat(ui) : add Planification tab to TaskModal with dates, calendar sync, and recurrence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
1bdd3883aa feat(ui) : add i18n translations for calendar integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
22c3c3dbd1 feat(ui) : add DTOs and services for calendar fields, recurrence, and Zimbra settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
cb768e0ce1 feat : update MCP tools with calendar fields and add recurrence tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
b3d317284e feat : add RecurrenceHandler for auto-creating next recurring task
When a task transitions to a final status, archives the current task and creates
a new occurrence with recalculated dates. Adds TaskStatusRepository::findFirstNonFinal()
to assign the initial status to the new task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
5a47adace5 feat : add TaskCalendarProcessor for CalDAV sync after DB operations
Handles Patch (persist + sync + recurrence check) and Delete (remove + cleanup Zimbra events).
Updates TaskNumberProcessor to sync newly created tasks to calendar.
Wires TaskCalendarProcessor as processor for Patch/Delete on Task entity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
75c53632c8 feat : add Zimbra settings API (CRUD + test connection)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
97a8afe559 feat : add RecurrenceCalculator service for next occurrence dates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
bae6d10ece feat : add CalDavService for Zimbra CalDAV sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
a0306bb5b2 feat(ui) : sync task code in URL for deep-linking from Gitea
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
7e36b6fd49 feat : migration for TaskRecurrence, ZimbraConfiguration, and Task calendar fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
e688c69438 feat : add calendar fields to Task entity (dates, sync, recurrence)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:35 +01:00
Matthieu
e640e715bb feat : add ZimbraConfiguration entity for CalDAV settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
6784ee9ead feat : add TaskRecurrence entity with RecurrenceType enum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
fc6b6587f9 feat : add RecurrenceType backed enum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
aa38e20c00 chore : add sabre/vobject for CalDAV ICS generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
98370e0478 docs : fix plan review findings for Zimbra calendar integration
- Separate @Version from occurrenceCount (use dedicated version column)
- Fix processor chaining: TaskNumberProcessor for Post, TaskCalendarProcessor for Patch/Delete
- Detect status CHANGE to isFinal (not just current isFinal) to avoid duplicate recurrence
- Add DeleteTaskTool CalDAV cleanup for MCP deletions
- Add "Mes tâches" page update task (sort + columns)
- Use i18n for weekDays labels instead of hardcoded French
- Clarify documents/bookStackLinks NOT copied for recurring tasks
- Use multi-line getter/setter style note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
30fb36e668 docs : add Zimbra CalDAV calendar integration implementation plan
20 tasks covering entities, services, API resources, MCP tools,
frontend components, i18n, fixtures, and testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
bd01072831 docs : address spec review findings for Zimbra calendar integration
- Use TokenEncryptor for password (align with GiteaConfiguration)
- Replace Entity Listener with API Platform Processor for CalDAV sync
- Add calendarSyncError field for persistent error tracking
- Add validation rules for date fields
- Fix ICS format (VCALENDAR wrapper, UTC timezone)
- Add task number generation for recurring task auto-creation
- Add optimistic locking on TaskRecurrence
- Clear calendar UIDs on archived tasks
- Add API filters for date fields
- Document i18n for daysOfWeek
- Clarify MCP tool behavior and known limitations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
df58b09c2e docs : add Zimbra CalDAV calendar integration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
Matthieu
26c41f01c0 fix(ui) : hide archived groups in task creation and remove unused TaskDrawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00
gitea-actions
b66caf6824 chore: bump version to v0.3.5
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-18 16:58:27 +00:00
Matthieu
96cbb45e61 fix(api) : fix mark-all-read using undefined executeStatement on DQL query
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:47:31 +01:00
Matthieu
a8b899f7c4 feat(ui) : move client tickets to project sub-page and fix profile layout for clients
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
- Move client tickets from admin tab to /projects/[id]/client-tickets page
- Add "Tickets client" sidebar link under project navigation
- Fix profile page using portal layout for ROLE_CLIENT users
- Bump version to v0.3.4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:16:24 +01:00
Matthieu
766fddd417 chore : bump version to v0.3.3
Some checks failed
Auto Tag Develop / tag (push) Failing after 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:39:37 +01:00
Matthieu
1219f3e73e feat(ui) : add task list view with bulk actions, filters, and priority flag
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m25s
- Add TaskListItem component with checkbox, project color, priority flag
- Add TaskBulkActions toolbar (bulk status/user/priority/effort/group update, delete)
- Add list view toggle button in my-tasks and project pages
- Add Priorité and Effort filters to project page
- TaskCard supports showProjectColor prop (color in my-tasks, neutral in project)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:36:40 +01:00
Matthieu
ec35a1b2aa feat(ui) : improve time-tracking UX, responsive tags, and task priority flag
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
- Add duplicate button in time entry drawer
- Make time entry blocks and list responsive (tags wrap, hide on narrow)
- Replace date filter input with calendar icon next to month title
- Fix scroll to current hour in calendar (use gridBodyEl)
- Show project color on ticket code in task cards and my-tasks
- Add red flag icon for high priority tasks in kanban and my-tasks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:44:36 +01:00
Matthieu
0113c08a60 chore : bump version to v0.3.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:21 +01:00
Matthieu
c176511d97 feat(ui) : add app title with swap button in top nav bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:12 +01:00
Matthieu
64de971872 feat(ui) : improve textarea description fields with vertical resize
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:11:00 +01:00
Matthieu
3dcc5c21a2 chore : bump version to v0.3.0
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:31 +01:00
Matthieu
47768c0f02 feat(time-tracking) : redesign calendar blocks and view mode switcher
Restyle time entry blocks with title on top, project below, tags
bottom-left, duration bottom-right. Checkerboard pattern for entries
without project. Pill-style view mode switcher. Link DateFilter mode
to main view mode and remove redundant toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
Matthieu
b278b8a23a feat(ui) : improve sidebar collapse button, logo and top nav
Move sidebar collapse toggle to mid-height floating circle button,
use LOGO_CARRE.png when collapsed, make timer button circular when
collapsed, reduce app bar height to 60px max.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
77 changed files with 8080 additions and 734 deletions

View File

@@ -1,8 +1,22 @@
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
}
},
"lesstime-local": {
"command": "docker",
"args": [
"exec",
"-i",
"php-lesstime-fpm",
"php",
"bin/console",
"mcp:server"
]
}
}
}
}

View File

@@ -12,10 +12,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure
```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink)
src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
src/Service/ # Services métier (NotificationService)
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
src/Enum/ # PHP enums (RecurrenceType)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
@@ -30,10 +31,10 @@ docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
frontend/layouts/ # Layouts (default, portal)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
frontend/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
```
@@ -68,6 +69,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
### Tags & Versioning
- La version de l'app est dans `config/version.yaml` (paramètre `app.version`)
- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version
- Faire un commit séparé de bump : `chore : bump version to v<X.Y.Z>`
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
### Backend
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
@@ -97,7 +105,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
### MCP Server
- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
@@ -126,3 +134,5 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)

View File

@@ -16,7 +16,9 @@
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",

738
composer.lock generated
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": "1a611b09459bb0625242a9a0ea223107",
"content-hash": "0bdbfd9abe99ffd23a53df611d8a879c",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -1156,6 +1156,85 @@
},
"time": "2026-01-26T15:45:40+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "doctrine/collections",
"version": "2.6.0",
@@ -2549,6 +2628,191 @@
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "mcp/sdk",
"version": "v0.4.0",
@@ -3315,6 +3579,115 @@
},
"time": "2025-11-21T15:09:14+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.5.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba",
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0"
},
"time": "2026-03-01T00:58:56+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.2",
@@ -3889,6 +4262,290 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "sabre/uri",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51",
"reference": "38eeab6ed9eec435a2188db489d4649c56272c51",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2024-09-04T15:30:08+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.8",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2026-01-12T10:45:19+00:00"
},
{
"name": "sabre/xml",
"version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/a89257fd188ce30e456b841b6915f27905dfdbe3",
"reference": "a89257fd188ce30e456b841b6915f27905dfdbe3",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.4 || ^8.0",
"sabre/uri": ">=2.0,<4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-09-06T08:00:55+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.6",
@@ -8712,85 +9369,6 @@
],
"time": "2022-12-23T10:58:28+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",

View File

@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
@@ -685,38 +685,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false
* },
* }
* @psalm-type TwigConfig = array{
* form_themes?: list<scalar|Param|null>,
* globals?: array<string, array{ // Default: []
* id?: scalar|Param|null,
* type?: scalar|Param|null,
* value?: mixed,
* }>,
* autoescape_service?: scalar|Param|null, // Default: null
* autoescape_service_method?: scalar|Param|null, // Default: null
* cache?: scalar|Param|null, // Default: true
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
* debug?: bool|Param, // Default: "%kernel.debug%"
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
* auto_reload?: scalar|Param|null,
* optimizations?: int|Param,
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
* file_name_pattern?: list<scalar|Param|null>,
* paths?: array<string, mixed>,
* date?: array{ // The default format options used by the date filter.
* format?: scalar|Param|null, // Default: "F j, Y H:i"
* interval_format?: scalar|Param|null, // Default: "%d days"
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
* },
* number_format?: array{ // The default format options for the number_format filter.
* decimals?: int|Param, // Default: 0
* decimal_point?: scalar|Param|null, // Default: "."
* thousands_separator?: scalar|Param|null, // Default: ","
* },
* mailer?: array{
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
* },
* }
* @psalm-type SecurityConfig = array{
* access_denied_url?: scalar|Param|null, // Default: null
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
@@ -1291,8 +1259,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
* enable_docs?: bool|Param, // Enable the docs // Default: true
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
@@ -1641,12 +1609,154 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* },
* }
* @psalm-type MonologConfig = array{
* use_microseconds?: scalar|Param|null, // Default: true
* channels?: list<scalar|Param|null>,
* handlers?: array<string, array{ // Default: []
* type?: scalar|Param|null,
* id?: scalar|Param|null,
* enabled?: bool|Param, // Default: true
* priority?: scalar|Param|null, // Default: 0
* level?: scalar|Param|null, // Default: "DEBUG"
* bubble?: bool|Param, // Default: true
* interactive_only?: bool|Param, // Default: false
* app_name?: scalar|Param|null, // Default: null
* include_stacktraces?: bool|Param, // Default: false
* process_psr_3_messages?: array{
* enabled?: bool|Param|null, // Default: null
* date_format?: scalar|Param|null,
* remove_used_context_fields?: bool|Param,
* },
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
* file_permission?: scalar|Param|null, // Default: null
* use_locking?: bool|Param, // Default: false
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
* date_format?: scalar|Param|null, // Default: "Y-m-d"
* ident?: scalar|Param|null, // Default: false
* logopts?: scalar|Param|null, // Default: 1
* facility?: scalar|Param|null, // Default: "user"
* max_files?: scalar|Param|null, // Default: 0
* action_level?: scalar|Param|null, // Default: "WARNING"
* activation_strategy?: scalar|Param|null, // Default: null
* stop_buffering?: bool|Param, // Default: true
* passthru_level?: scalar|Param|null, // Default: null
* excluded_http_codes?: list<array{ // Default: []
* code?: scalar|Param|null,
* urls?: list<scalar|Param|null>,
* }>,
* accepted_levels?: list<scalar|Param|null>,
* min_level?: scalar|Param|null, // Default: "DEBUG"
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
* buffer_size?: scalar|Param|null, // Default: 0
* flush_on_overflow?: bool|Param, // Default: false
* handler?: scalar|Param|null,
* url?: scalar|Param|null,
* exchange?: scalar|Param|null,
* exchange_name?: scalar|Param|null, // Default: "log"
* channel?: scalar|Param|null, // Default: null
* bot_name?: scalar|Param|null, // Default: "Monolog"
* use_attachment?: scalar|Param|null, // Default: true
* use_short_attachment?: scalar|Param|null, // Default: false
* include_extra?: scalar|Param|null, // Default: false
* icon_emoji?: scalar|Param|null, // Default: null
* webhook_url?: scalar|Param|null,
* exclude_fields?: list<scalar|Param|null>,
* token?: scalar|Param|null,
* region?: scalar|Param|null,
* source?: scalar|Param|null,
* use_ssl?: bool|Param, // Default: true
* user?: mixed,
* title?: scalar|Param|null, // Default: null
* host?: scalar|Param|null, // Default: null
* port?: scalar|Param|null, // Default: 514
* config?: list<scalar|Param|null>,
* members?: list<scalar|Param|null>,
* connection_string?: scalar|Param|null,
* timeout?: scalar|Param|null,
* time?: scalar|Param|null, // Default: 60
* deduplication_level?: scalar|Param|null, // Default: 400
* store?: scalar|Param|null, // Default: null
* connection_timeout?: scalar|Param|null,
* persistent?: bool|Param,
* message_type?: scalar|Param|null, // Default: 0
* parse_mode?: scalar|Param|null, // Default: null
* disable_webpage_preview?: bool|Param|null, // Default: null
* disable_notification?: bool|Param|null, // Default: null
* split_long_messages?: bool|Param, // Default: false
* delay_between_messages?: bool|Param, // Default: false
* topic?: int|Param, // Default: null
* factor?: int|Param, // Default: 1
* tags?: list<scalar|Param|null>,
* console_formatter_options?: mixed, // Default: []
* formatter?: scalar|Param|null,
* nested?: bool|Param, // Default: false
* publisher?: string|array{
* id?: scalar|Param|null,
* hostname?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 12201
* chunk_size?: scalar|Param|null, // Default: 1420
* encoder?: "json"|"compressed_json"|Param,
* },
* mongodb?: string|array{
* id?: scalar|Param|null, // ID of a MongoDB\Client service
* uri?: scalar|Param|null,
* username?: scalar|Param|null,
* password?: scalar|Param|null,
* database?: scalar|Param|null, // Default: "monolog"
* collection?: scalar|Param|null, // Default: "logs"
* },
* elasticsearch?: string|array{
* id?: scalar|Param|null,
* hosts?: list<scalar|Param|null>,
* host?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 9200
* transport?: scalar|Param|null, // Default: "Http"
* user?: scalar|Param|null, // Default: null
* password?: scalar|Param|null, // Default: null
* },
* index?: scalar|Param|null, // Default: "monolog"
* document_type?: scalar|Param|null, // Default: "logs"
* ignore_error?: scalar|Param|null, // Default: false
* redis?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* password?: scalar|Param|null, // Default: null
* port?: scalar|Param|null, // Default: 6379
* database?: scalar|Param|null, // Default: 0
* key_name?: scalar|Param|null, // Default: "monolog_redis"
* },
* predis?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* },
* from_email?: scalar|Param|null,
* to_email?: list<scalar|Param|null>,
* subject?: scalar|Param|null,
* content_type?: scalar|Param|null, // Default: null
* headers?: list<scalar|Param|null>,
* mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{
* id?: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* verbosity_levels?: array{
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
* },
* channels?: string|array{
* type?: scalar|Param|null,
* elements?: list<scalar|Param|null>,
* },
* }>,
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1654,12 +1764,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1667,13 +1777,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1681,13 +1791,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1695,6 +1805,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.2.10'
app.version: '0.3.9'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,777 @@
# Time Entry XLSX Export — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add XLSX export of time tracking data with detail + summary sheets for CIR/JEI tax documents.
**Architecture:** Custom Symfony controller generates XLSX via PhpSpreadsheet, returns BinaryFileResponse. Frontend adds an export button on time-tracking page that triggers download with current filters.
**Tech Stack:** PHP 8.4, Symfony 8.0, PhpSpreadsheet, Nuxt 4 / Vue 3
**Spec:** `docs/superpowers/specs/2026-03-24-time-entry-export-design.md`
---
### Task 1: Install PhpSpreadsheet
**Files:**
- Modify: `composer.json`
- [ ] **Step 1: Install the dependency**
```bash
docker exec -t php-lesstime-fpm composer require phpoffice/phpspreadsheet
```
- [ ] **Step 2: Verify installation**
```bash
docker exec -t php-lesstime-fpm php -r "require 'vendor/autoload.php'; new \PhpOffice\PhpSpreadsheet\Spreadsheet(); echo 'OK';"
```
Expected: `OK`
- [ ] **Step 3: Commit**
```bash
git add composer.json composer.lock
git commit -m "chore : add phpoffice/phpspreadsheet dependency for time entry export"
```
---
### Task 2: Add repository method for filtered time entries
**Files:**
- Modify: `src/Repository/TimeEntryRepository.php`
- [ ] **Step 1: Add `findForExport` method**
Add this method to `TimeEntryRepository`:
```php
/**
* @param int[]|null $tagIds
* @return TimeEntry[]
*/
public function findForExport(
\DateTimeImmutable $after,
\DateTimeImmutable $before,
?User $user = null,
?Project $project = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
->andWhere('te.startedAt >= :after')
->andWhere('te.startedAt < :before')
->setParameter('after', $after)
->setParameter('before', $before)
->orderBy('te.startedAt', 'ASC');
if (null !== $user) {
$qb->andWhere('te.user = :user')
->setParameter('user', $user);
}
if (null !== $project) {
$qb->andWhere('te.project = :project')
->setParameter('project', $project);
}
if (null !== $tagIds && [] !== $tagIds) {
$qb->join('te.tags', 'tag')
->andWhere('tag.id IN (:tagIds)')
->setParameter('tagIds', $tagIds);
}
return $qb->getQuery()->getResult();
}
```
- [ ] **Step 2: Add missing use statements if needed**
Ensure these imports are at the top of the file:
```php
use App\Entity\Project;
use App\Entity\User;
```
- [ ] **Step 3: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Repository/TimeEntryRepository.php
```
Expected: `No syntax errors detected`
- [ ] **Step 4: Commit**
```bash
git add src/Repository/TimeEntryRepository.php
git commit -m "feat : add findForExport repository method for time entries"
```
---
### Task 3: Create TimeEntryExportService
**Files:**
- Create: `src/Service/TimeEntryExportService.php`
- [ ] **Step 1: Create the service with all three sheets**
Create `src/Service/TimeEntryExportService.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\TimeEntry;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class TimeEntryExportService
{
private const array DETAIL_HEADERS = [
'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
];
private const array MONTH_NAMES = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
/**
* @param TimeEntry[] $timeEntries
*
* @return string Path to the generated temp file
*/
public function generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string
{
$spreadsheet = new Spreadsheet();
$this->buildDetailSheet($spreadsheet, $timeEntries);
$this->buildProjectRecapSheet($spreadsheet, $timeEntries);
$this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to);
$spreadsheet->setActiveSheetIndex(0);
$tempFile = tempnam(sys_get_temp_dir(), 'export_temps_') . '.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
return $tempFile;
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Détail');
// Headers
foreach (self::DETAIL_HEADERS as $col => $header) {
$colLetter = Coordinate::stringFromColumnIndex($col + 1);
$sheet->setCellValue("{$colLetter}1", $header);
}
$this->boldRow($sheet, 1, \count(self::DETAIL_HEADERS));
// Data rows
$row = 2;
foreach ($timeEntries as $entry) {
$duration = $this->computeDuration($entry);
$task = $entry->getTask();
$taskLabel = '';
if (null !== $task) {
$project = $task->getProject();
$code = $project?->getCode() ?? '';
$taskLabel = $code . '-' . $task->getNumber() . ' - ' . $task->getTitle();
}
$tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray();
$sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d'));
$sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? '');
$sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? '');
$sheet->setCellValue("D{$row}", $taskLabel);
$sheet->setCellValue("E{$row}", $entry->getTitle() ?? '');
$sheet->setCellValue("F{$row}", implode(', ', $tagLabels));
$sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i'));
$sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? '');
$sheet->setCellValue("I{$row}", round($duration, 2));
$sheet->setCellValue("J{$row}", $entry->getDescription() ?? '');
++$row;
}
// Total row
if ($row > 2) {
$sheet->setCellValue("H{$row}", 'Total');
$sheet->getStyle("H{$row}")->getFont()->setBold(true);
$sheet->setCellValue("I{$row}", "=SUM(I2:I" . ($row - 1) . ')');
$sheet->getStyle("I{$row}")->getFont()->setBold(true);
}
// Auto-size columns
foreach (range('A', 'J') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par projet');
// Aggregate: user → project → hours
$data = [];
$projects = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$projectName = $entry->getProject()?->getName() ?? 'Sans projet';
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$projects[$projectName] = true;
$data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration;
}
ksort($users);
ksort($projects);
$projectList = array_keys($projects);
$userList = array_keys($users);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($projectList as $project) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $project);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($projectList as $project) {
$val = round($data[$user][$project] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($projectList as $project) {
$projectTotal = 0;
foreach ($userList as $user) {
$projectTotal += $data[$user][$project] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
// Grand total
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par mois');
// Build month columns from the date range
$months = [];
$current = $from->modify('first day of this month');
$end = $to->modify('first day of this month');
while ($current <= $end) {
$key = $current->format('Y-m');
$label = self::MONTH_NAMES[(int) $current->format('n')] . ' ' . $current->format('Y');
$months[$key] = $label;
$current = $current->modify('+1 month');
}
// Aggregate: user → month-key → hours
$data = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$monthKey = $entry->getStartedAt()->format('Y-m');
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration;
}
ksort($users);
$userList = array_keys($users);
$monthKeys = array_keys($months);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($months as $label) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $label);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($monthKeys as $monthKey) {
$val = round($data[$user][$monthKey] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($monthKeys as $monthKey) {
$monthTotal = 0;
foreach ($userList as $user) {
$monthTotal += $data[$user][$monthKey] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
private function computeDuration(TimeEntry $entry): float
{
$start = $entry->getStartedAt();
$end = $entry->getStoppedAt();
if (null === $start || null === $end) {
return 0;
}
return ($end->getTimestamp() - $start->getTimestamp()) / 3600;
}
private function boldRow(Worksheet $sheet, int $row, int $colCount): void
{
for ($c = 1; $c <= $colCount; ++$c) {
$letter = Coordinate::stringFromColumnIndex($c);
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
}
}
}
```
- [ ] **Step 2: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Service/TimeEntryExportService.php
```
Expected: `No syntax errors detected`
- [ ] **Step 3: Commit**
```bash
git add src/Service/TimeEntryExportService.php
git commit -m "feat : add TimeEntryExportService generating XLSX with detail and recap sheets"
```
---
### Task 4: Create TimeEntryExportController
**Files:**
- Create: `src/Controller/TimeEntryExportController.php`
- [ ] **Step 1: Create the controller**
Create `src/Controller/TimeEntryExportController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): BinaryFileResponse
{
$afterStr = $request->query->getString('after');
$beforeStr = $request->query->getString('before');
if ('' === $afterStr || '' === $beforeStr) {
throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
}
try {
$after = new \DateTimeImmutable($afterStr);
$before = new \DateTimeImmutable($beforeStr);
} catch (\Exception) {
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
// Max range: 12 months
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// Authorization: non-admin users can only export their own data
$user = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $user */
$user = $this->security->getUser();
} else {
$userId = $request->query->getInt('user');
if ($userId > 0) {
$user = $this->entityManager->getRepository(User::class)->find($userId);
}
}
$project = null;
$projectId = $request->query->getInt('project');
if ($projectId > 0) {
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
}
/** @var int[] $tagIds */
$tagIds = array_filter(
array_map('intval', (array) $request->query->all('tags')),
fn (int $id) => $id > 0,
);
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$user,
$project,
$tagIds ?: null,
);
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
}
```
- [ ] **Step 2: Verify no syntax errors**
```bash
docker exec -t php-lesstime-fpm php -l src/Controller/TimeEntryExportController.php
```
Expected: `No syntax errors detected`
- [ ] **Step 3: Clear cache and verify route is registered**
```bash
docker exec -t php-lesstime-fpm php bin/console cache:clear
docker exec -t php-lesstime-fpm php bin/console debug:router | grep time_entry_export
```
Expected: line showing `time_entry_export` route mapped to `GET /api/time_entries/export`
- [ ] **Step 4: Commit**
```bash
git add src/Controller/TimeEntryExportController.php
git commit -m "feat : add TimeEntryExportController with auth, validation, and filters"
```
---
### Task 5: Manual backend smoke test
- [ ] **Step 1: Test missing params returns 400**
```bash
docker exec -t php-lesstime-fpm php bin/console debug:router time_entry_export
```
Then via curl (using admin fixture token):
```bash
curl -s -o /dev/null -w "%{http_code}" -b "BEARER=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)" "http://localhost:8082/api/time_entries/export"
```
Expected: `400`
- [ ] **Step 2: Test valid export returns XLSX**
```bash
TOKEN=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -s -o /tmp/test-export.xlsx -w "%{http_code}" -b "BEARER=${TOKEN}" "http://localhost:8082/api/time_entries/export?after=2025-01-01&before=2026-12-31"
echo ""
file /tmp/test-export.xlsx
```
Expected: HTTP `200`, file type contains `Microsoft Excel` or `Zip archive`
- [ ] **Step 3: Commit (no changes — verification only)**
---
### Task 6: Add frontend export method and i18n
**Files:**
- Modify: `frontend/services/time-entries.ts`
- Modify: `frontend/i18n/locales/fr.json`
- [ ] **Step 1: Add `getExportUrl` method to time-entries service**
Add this function inside `useTimeEntryService()` before the `return` statement in `frontend/services/time-entries.ts`:
```typescript
function getExportUrl(params: {
after: string
before: string
user?: number
project?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.user) query.set('user', String(params.user))
if (params.project) query.set('project', String(params.project))
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/api/time_entries/export?${query.toString()}`
}
```
Update the return statement to include `getExportUrl`:
```typescript
return { getByDateRange, getActive, create, update, remove, getExportUrl }
```
- [ ] **Step 2: Add i18n key**
In `frontend/i18n/locales/fr.json`, add `"export": "Exporter"` inside the `"timeEntries"` object.
- [ ] **Step 3: Commit**
```bash
git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json
git commit -m "feat : add getExportUrl to time-entries service and i18n key"
```
---
### Task 7: Add export button to time-tracking page
**Files:**
- Modify: `frontend/pages/time-tracking.vue`
- [ ] **Step 1: Add export button in template**
In `frontend/pages/time-tracking.vue`, find the `<div>` containing the `MalioSelect` for tags (the last filter). After its closing `</div>`, add:
```vue
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
```
- [ ] **Step 2: Add export function in script**
Add this function in the `<script setup>` section, after the existing helper functions (near `loadEntries`):
```typescript
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
```
- [ ] **Step 3: Verify dev server compiles without errors**
```bash
cd frontend && npx nuxi typecheck
```
Expected: no errors (or only pre-existing ones)
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat : add export button to time-tracking page"
```
---
### Task 8: End-to-end manual test
- [ ] **Step 1: Start dev server and test in browser**
1. Open `http://localhost:3002/time-tracking`
2. Verify the "Exporter" button appears in the filter bar
3. Select a date range with existing time entries
4. Click "Exporter"
5. Verify an `.xlsx` file downloads
- [ ] **Step 2: Open the XLSX and verify structure**
1. Feuille "Détail" — rows with Date, Utilisateur, Projet, etc. + total row
2. Feuille "Récap par projet" — users × projects cross-table
3. Feuille "Récap par mois" — users × months cross-table
- [ ] **Step 3: Test as non-admin user**
1. Log in as `alice` / `alice`
2. Export — verify only Alice's entries appear (even if user filter was different)
- [ ] **Step 4: Run PHP CS Fixer**
```bash
make php-cs-fixer-allow-risky
```
Fix any issues, then commit if needed:
```bash
git add -A && git commit -m "style : fix code style for time entry export"
```

View File

@@ -0,0 +1,278 @@
# Intégration Calendrier Zimbra CalDAV
**Date** : 2026-03-19
**Statut** : Validé
## Objectif
Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.
## Principes
- **Push uniquement** : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
- **Opt-in** : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
- **Sync synchrone** : les appels CalDAV se font au moment de l'action, timeout 5s
- **Configuration globale** : un seul compte Zimbra admin pour toute l'instance
- **Calendrier d'équipe** : toutes les tâches sync vont dans le même calendrier
## Modèle de données
### Nouveaux champs sur `Task`
| Champ | Type | Nullable | Default | Description |
|---|---|---|---|---|
| `scheduledStart` | `DateTimeImmutable` | oui | `null` | Début du créneau planifié |
| `scheduledEnd` | `DateTimeImmutable` | oui | `null` | Fin du créneau planifié |
| `deadline` | `DateTimeImmutable` | oui | `null` | Date d'échéance |
| `syncToCalendar` | `bool` | non | `false` | Opt-in pour la sync Zimbra |
| `calendarEventUid` | `string` | oui | `null` | UID du VEVENT dans Zimbra |
| `calendarTodoUid` | `string` | oui | `null` | UID du VTODO dans Zimbra |
| `calendarSyncError` | `string` | oui | `null` | Dernière erreur de sync CalDAV (null = OK) |
#### Règles de validation
- `scheduledEnd` requiert `scheduledStart` (et vice versa) — les deux ou aucun
- `scheduledEnd` doit être après `scheduledStart`
- `syncToCalendar = true` sans aucune date → ignoré silencieusement (pas de sync)
- `deadline` est indépendant des dates planifiées (peut exister seul)
### Nouvelle entité `TaskRecurrence`
| Champ | Type | Nullable | Description |
|---|---|---|---|
| `id` | `int` | non | PK auto-increment |
| `type` | `RecurrenceType` (PHP enum) | non | Enum backed string : `daily`, `weekly`, `monthly`, `yearly` |
| `interval` | `int` | non | Tous les X (jours/semaines/mois/ans) |
| `daysOfWeek` | `json` | oui | Jours de la semaine pour hebdo, ex: `["monday","wednesday"]` |
| `dayOfMonth` | `int` | oui | Jour du mois pour mensuel, ex: `15` |
| `weekOfMonth` | `int` | oui | Semaine du mois, ex: `1` pour "le 1er X du mois" |
| `endDate` | `Date` | oui | Fin de la récurrence (null = infini) |
| `maxOccurrences` | `int` | oui | Nombre max d'occurrences (alternatif à endDate) |
| `occurrenceCount` | `int` | non | Compteur d'occurrences créées (default 0) |
### Relations
- `Task.recurrence``ManyToOne` vers `TaskRecurrence` (nullable)
- `TaskRecurrence.tasks``OneToMany` vers `Task`
### Nouvelle entité `ZimbraConfiguration`
| Champ | Type | Nullable | Description |
|---|---|---|---|
| `id` | `int` | non | PK auto-increment |
| `serverUrl` | `string` | non | URL CalDAV Zimbra |
| `username` | `string` | non | Compte Zimbra |
| `encryptedPassword` | `string` | non | Mot de passe chiffré via `TokenEncryptor` (même pattern que `GiteaConfiguration`) |
| `calendarPath` | `string` | non | Chemin complet du calendrier, ex: `/dav/user@domain.com/Calendar/` |
| `enabled` | `bool` | non | Activer/désactiver la sync (default false) |
## Service CalDAV
### `CalDavService`
Dépendances : `sabre/vobject` pour la génération ICS, requêtes HTTP via `Symfony\Contracts\HttpClient`.
Le service utilise la `ZimbraConfiguration` pour construire l'URL CalDAV complète : `{serverUrl}{calendarPath}{uid}.ics`. Le mot de passe est déchiffré via `TokenEncryptor` avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.
#### Méthodes
- `createEvent(Task): string` — crée un VEVENT (créneau planifié), retourne l'UID
- `createTodo(Task): string` — crée un VTODO (deadline), retourne l'UID
- `updateEvent(Task): void` — met à jour le VEVENT existant
- `updateTodo(Task): void` — met à jour le VTODO existant
- `deleteEvent(string $uid): void` — supprime le VEVENT par UID
- `deleteTodo(string $uid): void` — supprime le VTODO par UID
- `testConnection(): bool` — teste la connexion CalDAV
#### Format ICS
Toutes les dates sont envoyées en **UTC** (suffixe `Z`). Les composants sont wrappés dans un document iCalendar complet :
**VEVENT (créneau planifié)** :
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VEVENT
UID:{calendarEventUid}
SUMMARY:[PROJET-NUM] Titre de la tâche
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
DTEND:{scheduledEnd en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
RRULE:{rrule si récurrence}
END:VEVENT
END:VCALENDAR
```
**VTODO (deadline)** :
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VTODO
UID:{calendarTodoUid}
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
DUE:{deadline en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
END:VTODO
END:VCALENDAR
```
Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.
## Logique de sync
### Déclenchement
Un **API Platform State Processor** (`TaskCalendarProcessor`) qui décore le persist/remove processor. La sync CalDAV est appelée **après** le flush en BDD, jamais pendant la transaction. Cela garantit :
- La tâche est sauvegardée même si Zimbra est down
- Pas de blocage de transaction DB par les appels HTTP
Pour les **MCP tools**, le `CalDavService` doit être appelé explicitement après le `flush()` dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).
### Matrice d'actions
| Action Lesstime | Effet CalDAV |
|---|---|
| Tâche créée/modifiée avec `syncToCalendar=true` et dates renseignées | Crée ou met à jour VEVENT + VTODO |
| `syncToCalendar` décoché | Supprime VEVENT + VTODO si existants |
| Tâche supprimée | Supprime VEVENT + VTODO si existants |
| Tâche récurrente passe en `isFinal` | Tâche archivée (`archived=true`), événements **conservés** dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent |
| Dates retirées | Supprime les events correspondants |
### Gestion des erreurs
- Timeout CalDAV : 5 secondes
- En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
- L'erreur est persistée dans `calendarSyncError` (visible dans l'UI comme indicateur rouge)
- Les UIDs CalDAV restent `null` si la création a échoué
- En cas de succès après un échec précédent, `calendarSyncError` est remis à `null`
## Tâches récurrentes
### Comportement
1. L'utilisateur crée une tâche avec récurrence dans Lesstime
2. **Zimbra** : un seul VEVENT avec `RRULE` est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
3. **Lesstime** : une seule tâche existe à la fois
4. Quand la tâche passe en statut `isFinal` :
- La tâche est archivée automatiquement (`archived = true`)
- Les événements Zimbra sont **conservés** (historique)
- Les `calendarEventUid` et `calendarTodoUid` de la tâche archivée sont **vidés** (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée
- Une nouvelle tâche est créée avec :
- Même titre, description, assigné, tags, projet, groupe, effort, priorité
- Nouveau `number` généré via `findMaxNumberByProjectForUpdate` (même pattern transactionnel que `TaskNumberProcessor`)
- Statut réinitialisé au premier statut (position la plus basse)
- Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
- `calendarEventUid` pointant vers le même VEVENT récurrent
- Nouveau `calendarTodoUid` (nouvelle deadline)
- `occurrenceCount` incrémenté sur `TaskRecurrence` (avec lock optimiste `@ORM\Version` pour éviter les doublons en cas de concurrence)
5. Si `maxOccurrences` ou `endDate` atteint, la récurrence s'arrête (pas de nouvelle tâche créée)
### Calcul de la prochaine date
La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :
- **Daily** : `scheduledStart + interval jours`
- **Weekly** : prochain jour de `daysOfWeek` à partir de `scheduledStart + interval semaines`
- **Monthly** : même `dayOfMonth` ou même `weekOfMonth`+jour, mois `+ interval`
- **Yearly** : même date, année `+ interval`
La durée du créneau (`scheduledEnd - scheduledStart`) est conservée.
## Frontend
### Onglet "Planification" dans TaskModal
La modale tâche existante aura 2 onglets :
**Onglet "Détails"** (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe
**Onglet "Planification"** (nouveau) :
#### Bloc Dates
- Date planifiée début (`datetime-local` picker)
- Date planifiée fin (`datetime-local` picker)
- Deadline (`date` picker)
#### Bloc Calendrier
- Checkbox "Envoyer au calendrier" (décoché par défaut)
- Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)
#### Bloc Récurrence
- Toggle "Tâche récurrente"
- Si activé :
- Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
- Intervalle : "Tous les X ..." (input number)
- Conditionnel selon le type :
- Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
- Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
- Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)
### Affichage des dates
**Cartes Kanban (`TaskCard`)** :
- Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
- Icône calendrier si `syncToCalendar` activé
- Icône récurrence si tâche récurrente
**Vue liste (`TaskListItem`)** :
- Colonne "Planifié" (date début)
- Colonne "Deadline"
- Icône récurrence si tâche récurrente
**Page "Mes tâches"** :
- Même affichage que la vue liste
- Tri possible par deadline ou date planifiée
### Page Admin — Configuration Zimbra
Nouveau bloc dans la page admin existante :
- URL du serveur CalDAV (input text)
- Nom d'utilisateur (input text)
- Mot de passe (input password)
- Chemin du calendrier (input text)
- Toggle activer/désactiver
- Bouton "Tester la connexion" (toast succès/erreur)
Accessible uniquement `ROLE_ADMIN`.
## MCP Tools
### Mise à jour des tools existants
`create-task` et `update-task` : nouveaux paramètres optionnels :
- `scheduledStart` (string datetime ISO)
- `scheduledEnd` (string datetime ISO)
- `deadline` (string datetime ISO)
- `syncToCalendar` (bool)
### Nouveaux tools
- `create-task-recurrence` — paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?
- `update-task-recurrence` — paramètres : recurrenceId, + mêmes champs optionnels
- `delete-task-recurrence` — paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant
## API Filters
Ajouter sur `Task` les filtres API Platform suivants :
- `DateFilter` sur `scheduledStart`, `scheduledEnd`, `deadline` (pour le tri et filtrage par plage de dates)
- `BooleanFilter` sur `syncToCalendar`
- `OrderFilter` sur `scheduledStart`, `deadline`
### Valeurs stockées en JSON (i18n)
Les `daysOfWeek` dans `TaskRecurrence` sont stockés en anglais (`monday`, `tuesday`...) — les labels traduits sont gérés uniquement côté frontend via i18n.
## Dépendances PHP
- `sabre/vobject` — génération/parsing ICS (VEVENT, VTODO, RRULE)
- `symfony/http-client` — requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)
## Limitations connues
- Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
- Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.

View File

@@ -0,0 +1,144 @@
# Export temps suivi de temps (XLSX)
**Ticket** : LST-41
**Date** : 2026-03-24
**Statut** : Approuvé
## Contexte
Les exports de suivi de temps sont nécessaires pour constituer des dossiers CIR (Crédit Impôt Recherche) et JEI (Jeune Entreprise Innovante). Ces dossiers exigent une ventilation détaillée du temps passé par collaborateur, par projet et par mois.
## Décisions
- **Format** : XLSX (via PhpSpreadsheet côté backend)
- **Déclenchement** : bouton "Exporter" sur la page time-tracking, reprenant les filtres en cours
- **Récap** : double tableau croisé (user × projet + user × mois)
## Architecture
```
Frontend Backend
───────── ───────
Bouton "Exporter"
→ GET /api/time_entries/export → TimeEntryExportController
?after=2026-01-01 → Validation params + authz
&before=2026-03-31 → TimeEntryRepository (query)
&user=5 → TimeEntryExportService (XLSX)
&project=5 → BinaryFileResponse (.xlsx)
&tags[]=2
```
## Backend
### Dépendance
`phpoffice/phpspreadsheet` ajouté via Composer.
### TimeEntryExportController
- Fichier : `src/Controller/TimeEntryExportController.php`
- Route : `GET /api/time_entries/export` avec `priority: 1`
- Sécurité : `#[IsGranted('ROLE_USER')]`
- **Autorisation** : si l'utilisateur n'a pas `ROLE_ADMIN`, le filtre `user` est forcé à l'utilisateur courant (ignore toute valeur fournie). Seuls les admins peuvent exporter les données d'autres utilisateurs ou de tous les utilisateurs.
- Paramètres query (IDs numériques, pas d'IRIs — c'est un controller custom, pas API Platform) :
- `after` (obligatoire) — date YYYY-MM-DD
- `before` (obligatoire) — date YYYY-MM-DD
- `user` (optionnel) — ID numérique (ex: `5`)
- `project` (optionnel) — ID numérique (ex: `5`)
- `tags[]` (optionnel) — tableau d'IDs numériques (ex: `tags[]=2&tags[]=3`)
- **Validation** :
- `after` et `before` obligatoires, sinon 400 Bad Request
- Plage maximale : 12 mois, sinon 400 Bad Request
- Si aucune entrée trouvée : retourne un XLSX avec en-têtes uniquement (pas d'erreur)
- Construit une query Doctrine avec ces filtres
- Appelle `TimeEntryExportService::generate()`
- Retourne `BinaryFileResponse` avec header `Content-Disposition: attachment; filename="export-temps-YYYY-MM-DD_YYYY-MM-DD.xlsx"`
### TimeEntryExportService
- Fichier : `src/Service/TimeEntryExportService.php`
- Méthode : `generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string` (retourne le chemin du fichier temp)
#### Feuille 1 — "Détail"
Toutes les entrées triées par date croissante.
| Colonne | Source | Format |
|---------|--------|--------|
| Date | `startedAt` | YYYY-MM-DD |
| Utilisateur | `user.username` | texte |
| Projet | `project.name` | texte (vide si null) |
| Tâche | `task` | "{code}-{number} - {title}" (vide si null) |
| Titre | `title` | texte |
| Tags | `tags` | labels séparés par ", " |
| Début | `startedAt` | HH:mm |
| Fin | `stoppedAt` | HH:mm (vide si null) |
| Durée (h) | calculée | nombre décimal (ex: 3.50) |
| Description | `description` | texte |
- En-têtes en gras
- Colonnes auto-dimensionnées
- Ligne de total en bas (somme de la colonne Durée)
#### Feuille 2 — "Récap par projet"
Tableau croisé dynamique :
- Lignes = utilisateurs (triés alphabétiquement)
- Colonnes = projets (triés alphabétiquement)
- Cellules = total heures (décimal)
- Dernière colonne = total par utilisateur
- Dernière ligne = total par projet
#### Feuille 3 — "Récap par mois"
Tableau croisé dynamique :
- Lignes = utilisateurs (triés alphabétiquement)
- Colonnes = mois de la période (format "Mars 2026")
- Cellules = total heures (décimal)
- Dernière colonne = total par utilisateur
- Dernière ligne = total par mois
## Frontend
### Page time-tracking
- Ajout d'un bouton "Exporter" dans la barre d'actions (à côté des filtres existants)
- Icône de téléchargement + label "Exporter"
- Au clic : construit l'URL `/api/time_entries/export` avec les filtres actuels (période affichée, user sélectionné, projet sélectionné, tags sélectionnés) et déclenche le téléchargement
### Service time-entries.ts
Ajout d'une méthode :
```typescript
function getExportUrl(params: {
after: string // YYYY-MM-DD
before: string // YYYY-MM-DD
user?: number // ID numérique
project?: number // ID numérique
tags?: number[] // tableau d'IDs
}): string
```
Construit l'URL complète avec query params. Le téléchargement est déclenché via un élément `<a>` temporaire avec attribut `download` (le cookie JWT est envoyé automatiquement sur une requête same-origin). En cas d'erreur, un toast est affiché.
### i18n
- `timeEntries.export` → "Exporter" (fr)
## Sécurité
- Accessible à `ROLE_USER` (même niveau que la consultation des time entries)
- **Non-admin : export limité à ses propres données** (filtre `user` forcé côté serveur)
- Le fichier XLSX est généré dans un fichier temporaire et supprimé après envoi
- Les filtres utilisent des IDs numériques (controller custom, pas d'IRI)
## Langue
Le contenu du XLSX est toujours en français (noms de feuilles, en-têtes de colonnes, noms de mois). C'est volontaire car les documents CIR/JEI sont des dossiers destinés à l'administration française.
## Hors scope
- Export PDF
- Export CSV
- Stockage des exports générés
- Planification d'exports automatiques

View File

@@ -0,0 +1,248 @@
/*
* Dark theme overrides
* Automatically applied when <html class="dark"> is set.
* Overrides existing Tailwind utilities so components need zero changes.
*/
/* ── Backgrounds ── */
.dark .bg-white {
background-color: #1e1f2b !important;
}
.dark .bg-tertiary-500 {
background-color: #262838 !important;
}
.dark .bg-neutral-50 {
background-color: #262838 !important;
}
.dark .bg-neutral-100 {
background-color: #2e3045 !important;
}
.dark .bg-neutral-200 {
background-color: #363952 !important;
}
/* ── Hover backgrounds ── */
.dark .hover\:bg-neutral-50:hover {
background-color: #2e3045 !important;
}
.dark .hover\:bg-neutral-100:hover {
background-color: #363952 !important;
}
.dark .hover\:bg-neutral-200:hover {
background-color: #3a3d54 !important;
}
.dark .hover\:bg-neutral-300:hover {
background-color: #3a3d54 !important;
}
.dark .hover\:shadow-md:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important;
}
/* ── Text ── */
.dark .text-neutral-900 {
color: #e5e5e5 !important;
}
.dark .text-neutral-800 {
color: #d4d4d8 !important;
}
.dark .text-neutral-700 {
color: #a1a1aa !important;
}
.dark .text-neutral-600 {
color: #8b8b9a !important;
}
.dark .text-neutral-500 {
color: #71717a !important;
}
.dark .text-neutral-400 {
color: #606070 !important;
}
.dark .text-neutral-300 {
color: #52525b !important;
}
/* ── Hover text ── */
.dark .hover\:text-neutral-700:hover {
color: #d4d4d8 !important;
}
.dark .hover\:text-neutral-600:hover {
color: #a1a1aa !important;
}
/* ── Borders ── */
.dark .border-neutral-200 {
border-color: #3a3d54 !important;
}
.dark .border-neutral-100 {
border-color: #2e3045 !important;
}
.dark .border-neutral-300 {
border-color: #3a3d54 !important;
}
.dark .hover\:border-neutral-300:hover {
border-color: #4a4d64 !important;
}
.dark .hover\:border-neutral-400:hover {
border-color: #4a4d64 !important;
}
/* ── Ring ── */
.dark .ring-black\/5 {
--tw-ring-color: rgb(255 255 255 / 0.05) !important;
}
/* ── Specific component overrides ── */
/* Modal header bg */
.dark .bg-neutral-50\/80 {
background-color: rgb(38 40 56 / 0.8) !important;
}
/* Sidebar collapse button */
.dark .shadow-sm {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important;
}
/* User dropdown */
.dark .shadow-lg {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important;
}
/* Forms: inputs, selects, textareas */
.dark input:not([type="checkbox"]):not([type="radio"]),
.dark textarea,
.dark select {
background-color: #1e1f2b !important;
color: #e5e5e5 !important;
border-color: #3a3d54 !important;
}
.dark input:not([type="checkbox"]):not([type="radio"])::placeholder,
.dark textarea::placeholder {
color: #606070 !important;
}
.dark input:not([type="checkbox"]):not([type="radio"]):focus,
.dark textarea:focus,
.dark select:focus {
border-color: #222783 !important;
}
/* Labels */
.dark label {
color: #a1a1aa;
}
/* ── Malio Layer UI components ── */
/* MalioSelect: floating label has hardcoded background: white */
.dark .floating-label {
background: #1e1f2b !important;
color: #a1a1aa !important;
}
/* MalioSelect: text-black used for selected value and options */
.dark .text-black {
color: #e5e5e5 !important;
}
.dark .text-black\/60 {
color: #71717a !important;
}
.dark .text-black\/40 {
color: #606070 !important;
}
/* MalioSelect: border-black used when option is selected */
.dark .border-black {
border-color: #a1a1aa !important;
}
/* MalioSelect: border-m-muted default border */
.dark .border-m-muted {
border-color: #3a3d54 !important;
}
/* MalioSelect: dropdown option hover background */
.dark .bg-m-muted\/10 {
background-color: rgb(160 174 192 / 0.15) !important;
}
/* MalioSelect: dropdown shadow */
.dark .shadow-2xl {
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
}
/* Checkbox/radio hardcoded black borders */
.dark .inp-cbx + .cbx svg {
stroke: #e5e5e5 !important;
}
.dark .inp-cbx + .cbx {
border-color: #a1a1aa !important;
}
/* Red/colored backgrounds for buttons */
.dark .bg-red-50 {
background-color: rgb(127 29 29 / 0.2) !important;
}
.dark .hover\:bg-red-100:hover {
background-color: rgb(127 29 29 / 0.3) !important;
}
.dark .bg-blue-50 {
background-color: rgb(30 58 138 / 0.2) !important;
}
/* Datetime/date input color-scheme for dark mode */
.dark input[type="datetime-local"],
.dark input[type="date"],
.dark input[type="time"] {
color-scheme: dark;
}
/* Scrollbar */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: #1e1f2b;
}
.dark ::-webkit-scrollbar-thumb {
background: #3a3d54;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d64;
}

View File

@@ -0,0 +1,124 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('zimbra.settings.title') }}</h2>
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.serverUrl"
:label="$t('zimbra.settings.serverUrl')"
:placeholder="$t('zimbra.settings.serverUrlPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.username"
:label="$t('zimbra.settings.username')"
:placeholder="$t('zimbra.settings.usernamePlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.calendarPath"
:label="$t('zimbra.settings.calendarPath')"
:placeholder="$t('zimbra.settings.calendarPathPlaceholder')"
input-class="w-full"
/>
<div>
<MalioInputText
v-model="form.password"
:label="$t('zimbra.settings.password')"
input-class="w-full"
type="password"
/>
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
{{ $t('zimbra.settings.passwordConfigured') }}
</p>
</div>
<label class="flex cursor-pointer items-center gap-2">
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
</label>
<div class="flex gap-3">
<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('zimbra.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('zimbra.settings.testConnection') }}
</button>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService()
const form = reactive({
serverUrl: '',
username: '',
calendarPath: '',
password: '',
enabled: false,
})
const hasPassword = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)
async function loadSettings() {
const settings = await getSettings()
form.serverUrl = settings.serverUrl ?? ''
form.username = settings.username ?? ''
form.calendarPath = settings.calendarPath ?? ''
form.enabled = settings.enabled
hasPassword.value = settings.hasPassword
}
async function handleSave() {
isSaving.value = true
try {
const result = await saveSettings({
serverUrl: form.serverUrl.trim() || null,
username: form.username.trim() || null,
calendarPath: form.calendarPath.trim() || null,
password: form.password || null,
enabled: form.enabled,
})
hasPassword.value = result.hasPassword
form.password = ''
testResult.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
try {
const result = await testConnection()
testResult.value = result.success
} catch {
testResult.value = false
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>

View File

@@ -73,6 +73,7 @@
v-model="editForm.description"
rows="5"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
style="resize: vertical; min-height: 140px; max-height: 500px"
/>
</div>

View File

@@ -0,0 +1,131 @@
<template>
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
<!-- Select all checkbox -->
<div
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
@click="emit('toggle-all')"
>
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
</div>
<span class="text-xs font-medium text-neutral-500">
{{ selectedCount }}/{{ totalCount }}
</span>
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
<!-- Bulk status -->
<MalioSelect
:model-value="null"
:options="statusOptions"
label="Status"
empty-option-label="Status"
min-width="!w-32"
text-field="text-xs"
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
/>
<!-- Bulk user -->
<MalioSelect
:model-value="null"
:options="userOptions"
label="User"
empty-option-label="User"
min-width="!w-32"
text-field="text-xs"
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
/>
<!-- Bulk priority -->
<MalioSelect
:model-value="null"
:options="priorityOptions"
label="Priorité"
empty-option-label="Priorité"
min-width="!w-32"
text-field="text-xs"
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
/>
<!-- Bulk effort -->
<MalioSelect
:model-value="null"
:options="effortOptions"
label="Effort"
empty-option-label="Effort"
min-width="!w-32"
text-field="text-xs"
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
/>
<!-- Bulk group -->
<MalioSelect
v-if="groupOptions.length > 0"
:model-value="null"
:options="groupOptions"
label="Groupe"
empty-option-label="Groupe"
min-width="!w-32"
text-field="text-xs"
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
/>
<!-- Delete -->
<button
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
title="Supprimer"
@click="emit('bulk-delete')"
>
<Icon name="mdi:delete-outline" size="22" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
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 { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
const props = defineProps<{
selectedCount: number
totalCount: number
allSelected: boolean
someSelected: boolean
statuses: TaskStatus[]
users: UserData[]
priorities: TaskPriority[]
efforts: TaskEffort[]
groups: TaskGroup[]
}>()
const emit = defineEmits<{
(e: 'toggle-all'): void
(e: 'bulk-update', field: string, value: number): void
(e: 'bulk-archive'): void
(e: 'bulk-delete'): void
}>()
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id }))
)
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
)
const groupOptions = computed(() =>
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
)
</script>

View File

@@ -9,7 +9,17 @@
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-1">
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
<span
v-if="task.project && task.number"
class="text-xs font-semibold"
:class="showProjectColor ? '' : 'text-neutral-400'"
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
>{{ task.project.code }}{{ task.number }}</span>
<Icon
v-if="task.priority?.label === 'Haute'"
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
@@ -44,6 +54,29 @@
>
{{ tag.label }}
</span>
<!-- Deadline badge -->
<span
v-if="task.deadline"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: deadlineColor }"
:title="task.deadline"
>
{{ formatDeadline(task.deadline) }}
</span>
<!-- Calendar sync icon -->
<Icon
v-if="task.syncToCalendar"
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
size="14"
/>
<!-- Recurrence icon -->
<Icon
v-if="task.recurrence"
name="mdi:repeat"
class="text-blue-500"
size="14"
/>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
@@ -63,9 +96,12 @@
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
const props = defineProps<{
const props = withDefaults(defineProps<{
task: Task
}>()
showProjectColor?: boolean
}>(), {
showProjectColor: false,
})
const emit = defineEmits<{
(e: 'click'): void
@@ -87,6 +123,18 @@ function onPlay() {
timerStore.startFromTask(props.task)
}
const deadlineColor = computed(() => {
if (!props.task.deadline) return ''
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
if (daysLeft < 0) return '#DC2626'
if (daysLeft < 2) return '#F59E0B'
return '#9CA3AF'
})
function formatDeadline(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
}
function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(props.task.id))

View File

@@ -1,327 +0,0 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
label="Titre"
input-class="w-full"
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true"
/>
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
/>
<MalioSelect
v-model="form.statusId"
:options="statusOptions"
label="Statut"
empty-option-label="Aucun statut"
min-width="w-full"
/>
<MalioSelect
v-model="form.effortId"
:options="effortOptions"
label="Effort"
empty-option-label="Aucun effort"
min-width="w-full"
/>
<MalioSelect
v-model="form.priorityId"
:options="priorityOptions"
label="Priorité"
empty-option-label="Aucune priorité"
min-width="w-full"
/>
<MalioSelect
v-model="form.assigneeId"
:options="userOptions"
label="User"
empty-option-label="Aucun utilisateur"
min-width="w-full"
/>
<MalioSelect
v-model="form.groupId"
:options="groupOptions"
label="Groupe"
empty-option-label="Aucun groupe"
min-width="w-full"
/>
<div class="mt-4">
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
<div class="flex flex-wrap gap-2">
<label
v-for="tag in tags"
:key="tag.id"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
:class="form.tagIds.includes(tag.id)
? 'text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
>
<input
type="checkbox"
class="hidden"
:value="tag.id"
:checked="form.tagIds.includes(tag.id)"
@change="toggleTag(tag.id)"
/>
{{ tag.label }}
</label>
</div>
</div>
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
v-if="isEditing"
type="button"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
Supprimer
</button>
<div class="flex gap-2">
<button
v-if="canArchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleArchive"
>
{{ $t('archive.archiveButton') }}
</button>
<button
v-if="canUnarchive"
type="button"
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
@click="handleUnarchive"
>
{{ $t('archive.unarchiveButton') }}
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</div>
</form>
<ConfirmDeleteTaskModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer>
</template>
<script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
task: Task | null
projectId: number
statuses: TaskStatus[]
efforts: TaskEffort[]
priorities: TaskPriority[]
tags: TaskTag[]
groups: TaskGroup[]
users: UserData[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const form = reactive({
title: '',
description: '',
statusId: null as number | null,
effortId: null as number | null,
priorityId: null as number | null,
assigneeId: null as number | null,
groupId: null as number | null,
tagIds: [] as number[],
})
const touched = reactive({
title: false,
})
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
)
const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id }))
)
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const groupOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id }))
)
const canArchive = computed(() => {
if (!isEditing.value || !props.task) return false
if (props.task.archived) return false
const status = props.statuses.find(s => s.id === props.task?.status?.id)
return !!status?.isFinal
})
const canUnarchive = computed(() => {
return isEditing.value && !!props.task?.archived
})
function toggleTag(id: number) {
const idx = form.tagIds.indexOf(id)
if (idx >= 0) {
form.tagIds.splice(idx, 1)
} else {
form.tagIds.push(id)
}
}
function populateForm(task: Task | null) {
if (task) {
form.title = task.title ?? ''
form.description = task.description ?? ''
form.statusId = task.status?.id ?? null
form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
} else {
form.title = ''
form.description = ''
form.statusId = null
form.effortId = null
form.priorityId = null
form.assigneeId = null
form.groupId = null
form.tagIds = []
}
touched.title = false
}
watch(() => props.modelValue, (open) => {
if (open) {
populateForm(props.task)
}
})
watch(() => props.task, (task) => {
if (props.modelValue) {
populateForm(task)
}
})
const { create, update, remove } = useTaskService()
async function handleDelete() {
if (!props.task) return
isSubmitting.value = true
try {
await remove(props.task.id)
confirmDeleteOpen.value = false
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleArchive() {
if (!props.task) return
const timerStore = useTimerStore()
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
}
isSubmitting.value = true
try {
await update(props.task.id, { archived: true })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleUnarchive() {
if (!props.task) return
isSubmitting.value = true
try {
await update(props.task.id, { archived: false })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
isSubmitting.value = true
try {
const payload: TaskWrite = {
title: form.title.trim(),
description: form.description.trim() || null,
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
if (isEditing.value && props.task) {
await update(props.task.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
:class="selected ? 'ring-2 ring-primary-500' : ''"
@click="emit('click')"
>
<!-- Content -->
<div class="min-w-0 flex-1">
<!-- Row 1: checkbox + code + flag -->
<div class="flex items-center gap-1.5">
<div
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
@click.stop="emit('toggle-select', task.id)"
>
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
</div>
<span
v-if="task.project && task.number"
class="text-xs font-semibold"
:class="showProjectColor ? '' : 'text-neutral-400'"
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
>
{{ task.project.code }}-{{ task.number }}
</span>
<Icon
v-if="task.priority?.label === 'Haute'"
name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600"
/>
</div>
<!-- Row 2: title -->
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<!-- Row 3: tags + status + deadline/calendar/recurrence -->
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
<span
v-if="task.status"
class="text-xs font-semibold uppercase text-neutral-400"
>
{{ task.status.label }}
</span>
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
Backlog
</span>
<!-- Deadline badge -->
<span
v-if="task.deadline"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: deadlineColor }"
:title="task.deadline"
>
{{ formatDeadline(task.deadline) }}
</span>
<!-- Calendar sync icon -->
<Icon
v-if="task.syncToCalendar"
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
size="13"
/>
<!-- Recurrence icon -->
<Icon
v-if="task.recurrence"
name="mdi:repeat"
class="text-blue-500"
size="13"
/>
</div>
</div>
<!-- Right: timer top, avatar bottom -->
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
<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>
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
showProjectColor?: boolean
selected?: boolean
}>(), {
showProjectColor: false,
selected: false,
})
const emit = defineEmits<{
(e: 'click'): void
(e: 'toggle-select', taskId: number): void
}>()
const timerStore = useTimerStore()
const deadlineColor = computed(() => {
if (!props.task.deadline) return ''
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
if (daysLeft < 0) return '#DC2626'
if (daysLeft < 2) return '#F59E0B'
return '#9CA3AF'
})
function formatDeadline(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
}
const isTimerOnTask = computed(() => {
const entry = timerStore.activeEntry
if (!entry?.task) return false
const entryTaskId = typeof entry.task === 'string'
? entry.task
: (entry.task['@id'] ?? entry.task.id)
const taskId = props.task['@id'] ?? props.task.id
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
})
</script>

View File

@@ -56,6 +56,25 @@
<!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6">
<button
v-for="tab in ['details', 'planning']"
:key="tab"
type="button"
class="px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab
? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab as 'details' | 'planning'"
>
{{ $t(`tasks.${tab}Tab`) }}
</button>
</nav>
</div>
<div v-show="activeTab === 'details'">
<!-- Title -->
<MalioInputText
v-model="form.title"
@@ -156,7 +175,10 @@
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
:size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
/>
</div>
@@ -196,6 +218,199 @@
v-if="hasBookStack && isEditing && task"
:task-id="task.id"
/>
</div>
<div v-show="activeTab === 'planning'" class="space-y-6">
<!-- Dates section -->
<div>
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledStart') }}</label>
<input
v-model="form.scheduledStart"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledEnd') }}</label>
<input
v-model="form.scheduledEnd"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div class="mt-4">
<div class="sm:w-1/2">
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.deadline') }}</label>
<input
v-model="form.deadline"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
<!-- Calendar sync -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.syncToCalendar"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
</label>
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
<Icon
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
size="18"
/>
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
</span>
</div>
</div>
<!-- Recurrence -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.isRecurring"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
</label>
<div v-if="form.isRecurring" class="mt-4 space-y-4">
<!-- Type -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
</select>
</div>
<!-- Interval -->
<MalioInputText
v-model="form.recurrenceInterval"
:label="$t('tasks.planning.interval')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="100"
/>
<!-- Weekly: days of week -->
<div v-if="form.recurrenceType === 'weekly'">
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="day in weekDays"
:key="day.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.recurrenceDaysOfWeek.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="day.value"
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
@change="toggleDay(day.value)"
/>
{{ day.label }}
</label>
</div>
</div>
<!-- Monthly options -->
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
{{ $t('tasks.planning.dayOfMonth') }}
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
{{ $t('tasks.planning.weekOfMonth') }}
</label>
</div>
<MalioInputText
v-if="form.monthlyMode === 'dayOfMonth'"
v-model="form.recurrenceDayOfMonth"
:label="$t('tasks.planning.dayOfMonthLabel')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="31"
/>
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option :value="1">1er</option>
<option :value="2">2ème</option>
<option :value="3">3ème</option>
<option :value="4">4ème</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
</select>
</div>
</div>
</div>
<!-- End of recurrence -->
<div class="space-y-3">
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="never" type="radio" />
{{ $t('tasks.planning.neverEnds') }}
</label>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
{{ $t('tasks.planning.afterOccurrences') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'occurrences'"
v-model="form.recurrenceMaxOccurrences"
type="number"
input-class="w-20"
min="1"
/>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="date" type="radio" />
{{ $t('tasks.planning.onDate') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'date'"
v-model="form.recurrenceEndDate"
type="date"
input-class="w-44"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div
@@ -279,6 +494,7 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/services/dto/project'
@@ -313,6 +529,7 @@ function close() {
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning'>('details')
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
@@ -336,6 +553,21 @@ const form = reactive({
tagIds: [] as number[],
clientTicketId: null as number | null,
projectId: null as number | null,
scheduledStart: '',
scheduledEnd: '',
deadline: '',
syncToCalendar: false,
isRecurring: false,
recurrenceType: 'daily' as string,
recurrenceInterval: '1',
recurrenceDaysOfWeek: [] as string[],
recurrenceDayOfMonth: '',
monthlyMode: 'dayOfMonth' as string,
recurrenceWeekOfMonth: 1,
recurrenceWeekDay: 'monday' as string,
recurrenceEnd: 'never' as string,
recurrenceMaxOccurrences: '',
recurrenceEndDate: '',
})
const touched = reactive({
@@ -360,7 +592,7 @@ const userOptions = computed(() =>
)
const groupOptions = computed(() => {
let filtered = props.groups
let filtered = props.groups.filter(g => !g.archived)
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
@@ -397,6 +629,22 @@ function toggleTag(id: number) {
}
}
const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
{ value: 'thursday', label: t('tasks.planning.days.thu') },
{ value: 'friday', label: t('tasks.planning.days.fri') },
{ value: 'saturday', label: t('tasks.planning.days.sat') },
{ value: 'sunday', label: t('tasks.planning.days.sun') },
])
function toggleDay(day: string) {
const idx = form.recurrenceDaysOfWeek.indexOf(day)
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
else form.recurrenceDaysOfWeek.push(day)
}
function populateForm(task: Task | null) {
if (task) {
form.title = task.title ?? ''
@@ -408,6 +656,42 @@ function populateForm(task: Task | null) {
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
form.syncToCalendar = task.syncToCalendar ?? false
if (task.recurrence) {
form.isRecurring = true
form.recurrenceType = task.recurrence.type
form.recurrenceInterval = String(task.recurrence.interval)
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
if (task.recurrence.maxOccurrences) {
form.recurrenceEnd = 'occurrences'
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
} else if (task.recurrence.endDate) {
form.recurrenceEnd = 'date'
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
} else {
form.recurrenceEnd = 'never'
}
} else {
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
}
} else {
form.title = ''
form.description = ''
@@ -419,6 +703,21 @@ function populateForm(task: Task | null) {
form.tagIds = []
form.clientTicketId = null
form.projectId = null
form.scheduledStart = ''
form.scheduledEnd = ''
form.deadline = ''
form.syncToCalendar = false
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
}
touched.title = false
touched.project = false
@@ -426,6 +725,7 @@ function populateForm(task: Task | null) {
watch(() => props.modelValue, async (open) => {
if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task)
@@ -459,6 +759,7 @@ watch(() => props.task, (task) => {
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([])
@@ -614,12 +915,42 @@ async function handleSubmit() {
project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
scheduledStart: form.scheduledStart || null,
scheduledEnd: form.scheduledEnd || null,
deadline: form.deadline || null,
syncToCalendar: form.syncToCalendar,
}
let savedTask: Task
if (isEditing.value && props.task) {
await update(props.task.id, payload)
savedTask = await update(props.task.id, payload)
} else {
await create(payload)
savedTask = await create(payload)
}
// Handle recurrence
if (form.isRecurring) {
const recPayload = {
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
interval: parseInt(form.recurrenceInterval) || 1,
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
? parseInt(form.recurrenceDayOfMonth) || null : null,
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
? form.recurrenceWeekOfMonth : null,
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
maxOccurrences: form.recurrenceEnd === 'occurrences'
? parseInt(form.recurrenceMaxOccurrences) || null : null,
}
if (savedTask.recurrence) {
await updateRecurrence(savedTask.recurrence.id, recPayload)
} else {
const recurrence = await createRecurrence(recPayload)
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
}
} else if (isEditing.value && props.task?.recurrence) {
await removeRecurrence(props.task.recurrence.id)
}
emit('saved')

View File

@@ -17,38 +17,39 @@
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
<div class="px-1.5 py-0.5 h-full overflow-hidden">
<!-- Full display: title + project + type dot + duration -->
<template v-if="sizeLevel >= 3">
<div class="flex items-center gap-1">
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Top: title + project -->
<div class="min-w-0">
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Bottom: tags left, duration right -->
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
<span
v-for="tag in entry.tags"
v-for="tag in visibleTags"
:key="tag.id"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
:style="{ backgroundColor: tag.color }"
>
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.label }}
</span>
<span
v-if="hiddenTagCount > 0"
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
>
+{{ hiddenTagCount }}
</span>
</div>
</template>
<!-- Medium: title + duration -->
<template v-else-if="sizeLevel === 2">
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
</template>
<!-- Small: title only -->
<template v-else-if="sizeLevel === 1">
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
</template>
<!-- Tiny: just a colored bar, no text -->
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
</div>
<!-- Resize handle bottom (outside block) -->
@@ -116,13 +117,22 @@ const sizeLevel = computed(() => {
return 0
})
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
const maxVisibleTags = computed(() => {
const total = props.totalColumns ?? 1
if (total >= 2) return 1
return 2
})
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
const hasProject = computed(() => !!props.entry.project)
const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const hex = (props.entry.project?.color ?? '#94a3b8').replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1
@@ -130,14 +140,28 @@ const blockStyle = computed(() => {
const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100
return {
const base: Record<string, string> = {
top: `${topPx}px`,
height: `${heightPx.value}px`,
backgroundColor: `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`,
color: `rgb(${r}, ${g}, ${b})`,
left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
}
if (hasProject.value) {
const hex = props.entry.project!.color.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
base.color = `rgb(${r}, ${g}, ${b})`
} else {
base.backgroundColor = '#e5e7eb'
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
base.backgroundSize = '12px 12px'
base.color = '#6b7280'
}
return base
})
// --- Click / Drag detection ---

View File

@@ -105,12 +105,22 @@
>
Supprimer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
<div class="flex gap-2">
<button
v-if="isEditing"
type="button"
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
@click="onDuplicate"
>
Dupliquer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
</div>
</div>
</form>
</AppDrawer>
@@ -231,6 +241,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
}
})
async function onDuplicate() {
if (!form.date || !form.startTime || !form.endTime) return
const { create } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISO(form.date, form.startTime),
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
await create(payload as TimeEntryWrite)
emit('saved')
isOpen.value = false
}
async function onDelete() {
if (!props.entry) return
const { remove } = useTimeEntryService()

View File

@@ -7,7 +7,7 @@
<div
v-for="entry in sortedEntries"
:key="entry.id"
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
@click="emit('editEntry', entry)"
>
<!-- Color bar -->
@@ -18,14 +18,14 @@
<!-- Main info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || $t('common.untitled') }}
</span>
<div class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || $t('common.untitled') }}
</div>
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in entry.tags"
:key="tag.id"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}

View File

@@ -1,27 +1,27 @@
<template>
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- Day headers -->
<div
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
v-for="day in days"
:key="day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center"
>
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }}
<!-- Grid body with sticky header -->
<div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
<!-- Day headers (sticky inside scroll container) -->
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
<div class="w-16 shrink-0 border-r border-neutral-200" />
<div
v-for="day in days"
:key="'header-' + day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center"
>
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div>
</div>
<!-- Grid body -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
<!-- Columns -->
<div class="relative flex">
<!-- Hour labels -->
<div class="w-16 shrink-0">
<div
@@ -134,7 +134,8 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div>
</div>
</div>
</div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div>
</template>
@@ -200,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
// Scroll to current hour on mount
onMounted(() => {
nextTick(() => {
if (!calendarEl.value) return
const scrollParent = getScrollParent()
if (!scrollParent) return
if (!gridBodyEl.value) return
const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const calendarTop = calendarEl.value.offsetTop
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
scrollParent.scrollTop = Math.max(0, scrollTarget)
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
})
})

View File

@@ -1,5 +1,5 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
<div class="flex h-full items-center justify-between">
<button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@@ -7,7 +7,26 @@
>
<Icon name="mdi:menu" size="24" />
</button>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
<button
type="button"
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
@click="toggleTitle"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
</div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<button
type="button"
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
@click="ui.toggleDarkMode()"
>
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
</button>
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
@@ -45,6 +64,13 @@ defineProps<{
const auth = useAuthStore()
const ui = useUiStore()
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
function toggleTitle() {
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
localStorage.setItem('appTitle', appTitle.value)
}
async function handleLogout() {
await auth.logout()
await navigateTo('/login')

View File

@@ -1,5 +1,5 @@
<template>
<div class="date-filter">
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
<VueDatePicker
ref="datepicker"
v-model="internalValue"
@@ -14,45 +14,11 @@
@update:model-value="onUpdate"
>
<template #trigger>
<div class="flex items-center gap-1">
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('day')"
>
{{ t('common.day') }}
</button>
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('week')"
>
{{ t('common.weekShort') }}
</button>
</div>
<div class="relative cursor-pointer">
<input
:value="displayValue"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="t('common.dateFilter')"
readonly
/>
<button
v-if="internalValue"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</div>
<button
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
>
<Icon name="mdi:calendar-blank" size="20" />
</button>
</template>
<template #action-buttons>
@@ -85,6 +51,7 @@ const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
pickerMode?: 'day' | 'week'
}>()
const emit = defineEmits<{
@@ -92,7 +59,7 @@ const emit = defineEmits<{
}>()
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = ref<'day' | 'week'>('week')
const mode = computed(() => props.pickerMode ?? 'week')
const internalValue = ref<Date | Date[] | null>(null)
const displayValue = computed(() => {
@@ -133,13 +100,6 @@ function formatShortDate(d: Date): string {
return `${day}/${month}`
}
function switchMode(newMode: 'day' | 'week') {
if (mode.value === newMode) return
mode.value = newMode
internalValue.value = null
emit('update:modelValue', null)
}
function onUpdate(value: Date | Date[] | null) {
if (!value) {
emit('update:modelValue', null)
@@ -163,7 +123,6 @@ function onClear() {
}
function selectToday() {
mode.value = 'day'
const today = new Date()
today.setHours(0, 0, 0, 0)
internalValue.value = today
@@ -171,7 +130,6 @@ function selectToday() {
}
function selectThisWeek() {
mode.value = 'week'
const now = new Date()
const day = now.getDay()
const monday = new Date(now)

View File

@@ -1,11 +1,11 @@
<template>
<button
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
class="flex items-center justify-center gap-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'
collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
]"
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"

View File

@@ -106,7 +106,47 @@
"deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"addTask": "Ajouter un ticket",
"editTask": "Modifier un ticket"
"editTask": "Modifier un ticket",
"detailsTab": "Détails",
"planningTab": "Planification",
"planning": {
"dates": "Dates",
"scheduledStart": "Début planifié",
"scheduledEnd": "Fin planifiée",
"deadline": "Deadline",
"calendar": "Calendrier",
"syncToCalendar": "Envoyer au calendrier Zimbra",
"syncOk": "Synchronisé",
"recurrence": "Récurrence",
"isRecurring": "Tâche récurrente",
"type": "Type",
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel",
"interval": "Intervalle",
"daysOfWeek": "Jours de la semaine",
"days": {
"mon": "Lu",
"tue": "Ma",
"wed": "Me",
"thu": "Je",
"fri": "Ve",
"sat": "Sa",
"sun": "Di"
},
"dayOfMonth": "Jour du mois",
"dayOfMonthLabel": "Jour (1-31)",
"weekOfMonth": "Semaine du mois",
"weekOfMonthLabel": "Semaine",
"dayLabel": "Jour",
"endRecurrence": "Fin de la récurrence",
"neverEnds": "Jamais",
"afterOccurrences": "Après X occurrences",
"occurrences": "Occurrences",
"onDate": "À une date",
"endDate": "Date de fin"
}
},
"users": {
"created": "Utilisateur créé avec succès.",
@@ -121,7 +161,8 @@
"deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps"
"editEntry": "Modifier un temps",
"export": "Exporter"
},
"archive": {
"title": "Archives",
@@ -146,7 +187,11 @@
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog",
"createTask": "Créer une tâche"
"createTask": "Créer une tâche",
"sortBy": "Trier par",
"sortDefault": "Par défaut",
"sortDeadline": "Échéance",
"sortScheduledStart": "Date planifiée"
},
"dashboard": {
"title": "Tableau de bord",
@@ -358,5 +403,35 @@
"noResults": "Aucun résultat",
"empty": "Aucun document lié"
}
},
"zimbra": {
"settings": {
"title": "Calendrier Zimbra",
"serverUrl": "URL du serveur CalDAV",
"serverUrlPlaceholder": "https://mail.ovh.com",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "user{'@'}domain.com",
"calendarPath": "Chemin du calendrier",
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
"password": "Mot de passe",
"passwordConfigured": "Mot de passe configuré",
"enabled": "Activer la synchronisation CalDAV",
"save": "Enregistrer",
"saved": "Configuration Zimbra enregistrée",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie",
"testFailed": "Connexion échouée"
}
},
"taskRecurrence": {
"created": "Récurrence créée",
"updated": "Récurrence mise à jour",
"deleted": "Récurrence supprimée"
},
"recurrence": {
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
}
}

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img
v-if="!sidebarIsCollapsed"
src="/malio.png"
@@ -26,9 +26,9 @@
/>
<img
v-else
src="/malio.png"
src="/LOGO_CARRE.png"
alt="Logo"
class="h-8 w-8 object-cover object-left"
class="w-[46px] h-[55px]"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@@ -86,11 +86,18 @@
sub
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/client-tickets`"
icon="mdi:ticket-outline"
label="Tickets client"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
</template>
<SidebarLink
to="/time-tracking"
icon="mdi:clock-outline"
icon="mdi:calendar-edit-outline"
label="Suivi de temps"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
@@ -108,19 +115,21 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex flex-col gap-2 items-center p-4">
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
<button
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()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="20"
/>
</button>
</div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">

View File

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
css: ['~/assets/css/dark.css'],
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')

View File

@@ -27,9 +27,9 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
</div>
</div>
</template>
@@ -45,9 +45,9 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'client-tickets', label: 'Tickets client' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
] as const
type TabKey = typeof tabs[number]['key']

View File

@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
useHead({ title: t('myTasks.title') })
@@ -48,9 +50,16 @@ const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// Sort
type SortOption = 'default' | 'deadline' | 'scheduledStart'
const sortBy = ref<SortOption>('default')
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection
const selectedTaskIds = reactive(new Set<number>())
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
@@ -152,6 +161,11 @@ async function loadTasks() {
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
if (sortBy.value === 'deadline') {
params['order[deadline]'] = 'asc'
} else if (sortBy.value === 'scheduledStart') {
params['order[scheduledStart]'] = 'asc'
}
tasks.value = await taskService.getFiltered(params)
}
@@ -164,9 +178,9 @@ async function loadAll() {
}
}
// Watch filters to reload tasks
// Watch filters and sort to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
() => { loadTasks() },
)
@@ -217,19 +231,89 @@ async function onDropBacklog(event: DragEvent) {
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
router.replace({ query: {} })
}
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
if (task.project?.code && task.number) {
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
}
}
watch(taskModalOpen, (open) => {
if (!open) {
router.replace({ query: {} })
}
})
async function onSaved() {
await loadTasks()
}
onMounted(() => {
loadAll()
function toggleTaskSelect(taskId: number) {
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId)
} else {
selectedTaskIds.add(taskId)
}
}
function toggleSelectAll(taskList: Task[]) {
if (selectedTaskIds.size === taskList.length) {
selectedTaskIds.clear()
} else {
taskList.forEach(t => selectedTaskIds.add(t.id))
}
}
async function onBulkUpdate(field: string, value: number) {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
const payload: Record<string, unknown> = {}
if (field === 'status') payload.status = `/api/task_statuses/${value}`
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
else if (field === 'group') payload.group = `/api/task_groups/${value}`
await Promise.all(ids.map(id => taskService.update(id, payload)))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkArchive() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkDelete() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.remove(id)))
selectedTaskIds.clear()
await loadTasks()
}
onMounted(async () => {
await loadAll()
const taskParam = route.query.task as string | undefined
if (taskParam) {
const dashIndex = taskParam.lastIndexOf('-')
if (dashIndex > 0) {
const code = taskParam.slice(0, dashIndex)
const num = Number(taskParam.slice(dashIndex + 1))
if (num) {
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
if (task) {
openTaskEdit(task)
}
}
}
}
})
</script>
@@ -247,24 +331,16 @@ onMounted(() => {
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button>
<div class="flex gap-1">
<button
class="flex items-center justify-center rounded-md p-1.5 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="18" />
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 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="18" />
</button>
</div>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
>
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
</div>
</div>
@@ -323,6 +399,17 @@ onMounted(() => {
text-field="text-sm"
text-value="text-sm"
/>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
<select
v-model="sortBy"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
</select>
</div>
</div>
</div>
@@ -351,6 +438,7 @@ onMounted(() => {
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
<p
@@ -379,6 +467,7 @@ onMounted(() => {
v-for="task in backlogTasks"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
</div>
@@ -392,57 +481,31 @@ onMounted(() => {
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<div
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<TaskBulkActions
:selected-count="selectedTaskIds.size"
:total-count="tasks.length"
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
:statuses="statuses"
:users="users"
:priorities="priorities"
:efforts="efforts"
:groups="groups"
@toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@bulk-delete="onBulkDelete"
/>
<TaskListItem
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"
:task="task"
show-project-color
:selected="selectedTaskIds.has(task.id)"
@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>
<div class="flex items-center gap-1.5">
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
</div>
@toggle-select="toggleTaskSelect"
/>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"

View File

@@ -41,6 +41,11 @@
v-model="form.description"
:label="$t('clientTicket.description')"
:size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/>
</div>

View File

@@ -1,4 +1,5 @@
<template>
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
<div class="mx-auto max-w-lg px-4 py-10">
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
@@ -45,12 +46,21 @@
@cancel="selectedFile = null"
/>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
const auth = useAuthStore()
const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
)
definePageMeta({
layout: false,
})
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)

View File

@@ -0,0 +1,265 @@
<template>
<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">
Tickets client
<span v-if="project" class="text-neutral-400"> {{ project.name }}</span>
</h1>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<select
v-model="filterStatus"
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option :value="null">Tous les statuts</option>
<option value="new">{{ $t('clientTicket.status.new') }}</option>
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
<option value="done">{{ $t('clientTicket.status.done') }}</option>
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
</select>
</div>
</div>
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="mt-4 space-y-3">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="rounded-lg border border-neutral-200 bg-white"
>
<div
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
@click="toggleExpand(ticket.id)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<button
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
:title="$t('clientTicket.changeStatus')"
@click.stop="openStatusChange(ticket)"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
<button
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
title="Supprimer"
@click.stop="onDelete(ticket)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="20"
class="text-neutral-400"
/>
</div>
</div>
<!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
<div v-if="ticket.url" class="mt-2">
<a
:href="ticket.url"
target="_blank"
class="text-xs text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
{{ ticket.statusComment }}
</div>
</div>
</div>
</div>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="statusModalOpen = false"
/>
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
CT-{{ String(statusTarget.number).padStart(3, '0') }} {{ statusTarget.title }}
</p>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
<select
v-model="newStatus"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null" disabled></option>
<option
v-for="s in availableStatusTransitions"
:key="s.value"
:value="s.value"
>
{{ s.label }}
</option>
</select>
</div>
<div v-if="newStatus === 'rejected'" class="mt-4">
<MalioInputTextArea
v-model="statusComment"
:label="$t('clientTicket.statusComment')"
:size="3"
/>
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
{{ $t('clientTicket.rejectionRequired') }}
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="statusModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
>
Confirmer
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import type { Project } from '~/services/dto/project'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Tickets client' })
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const project = ref<Project | null>(null)
const tickets = ref<ClientTicket[]>([])
const isLoading = ref(true)
const filterStatus = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const filteredTickets = computed(() => {
if (!filterStatus.value) return tickets.value
return tickets.value.filter(t => t.status === filterStatus.value)
})
// Status change
const statusModalOpen = ref(false)
const statusTarget = ref<ClientTicket | null>(null)
const newStatus = ref<string | null>(null)
const statusComment = ref('')
const rejectionError = ref(false)
const isUpdatingStatus = ref(false)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.value = true
}
async function confirmStatusChange() {
if (!statusTarget.value || !newStatus.value) return
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
rejectionError.value = true
return
}
isUpdatingStatus.value = true
try {
await clientTicketService.updateStatus(statusTarget.value.id, {
status: newStatus.value as ClientTicketStatus,
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
})
statusModalOpen.value = false
await loadTickets()
} finally {
isUpdatingStatus.value = false
}
}
async function onDelete(ticket: ClientTicket) {
await clientTicketService.remove(ticket.id)
await loadTickets()
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll({ project: projectId.value })
}
async function loadData() {
isLoading.value = true
try {
const [p, t] = await Promise.all([
projectService.getById(projectId.value),
clientTicketService.getAll({ project: projectId.value }),
])
project.value = p
tickets.value = t
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -11,6 +11,16 @@
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
title="Vue liste"
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
>
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
<button
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
title="Paramètres du projet"
@@ -58,11 +68,29 @@
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityFilterOptions"
label="Priorité"
empty-option-label="Toutes"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortFilterOptions"
label="Effort"
empty-option-label="Tous"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Kanban -->
<div class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in statuses"
:key="status.id"
@@ -100,6 +128,7 @@
<!-- Backlog -->
<div
v-if="viewMode === 'kanban'"
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
@dragover.prevent
@@ -118,6 +147,39 @@
</div>
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<TaskBulkActions
:selected-count="selectedTaskIds.size"
:total-count="filteredTasks.length"
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
:statuses="statuses"
:users="users"
:priorities="priorities"
:efforts="efforts"
:groups="groups"
@toggle-all="toggleSelectAll(filteredTasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@bulk-delete="onBulkDelete"
/>
<TaskListItem
v-for="task in filteredTasks"
:key="task.id"
:task="task"
:selected="selectedTaskIds.has(task.id)"
@click="openTaskEdit(task)"
@toggle-select="toggleTaskSelect"
/>
<p
v-if="filteredTasks.length === 0"
class="py-8 text-center text-sm text-neutral-400"
>
Aucun ticket
</p>
</div>
<TaskModal
v-model="taskDrawerOpen"
:task="selectedTask"
@@ -162,6 +224,7 @@ import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
const route = useRoute()
const router = useRouter()
const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Projet' })
@@ -191,6 +254,10 @@ 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 selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const viewMode = ref<'kanban' | 'list'>('kanban')
const selectedTaskIds = reactive(new Set<number>())
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
@@ -213,6 +280,14 @@ const statusFilterOptions = computed(() =>
statuses.value.map(s => ({ label: s.label, value: s.id }))
)
const priorityFilterOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id }))
)
const effortFilterOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) {
@@ -227,6 +302,12 @@ const filteredTasks = computed(() => {
if (selectedStatusId.value) {
result = result.filter(t => t.status?.id === selectedStatusId.value)
}
if (selectedPriorityId.value) {
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
}
if (selectedEffortId.value) {
result = result.filter(t => t.effort?.id === selectedEffortId.value)
}
return result
})
@@ -269,13 +350,23 @@ async function loadData() {
function openTaskCreate() {
selectedTask.value = null
taskDrawerOpen.value = true
router.replace({ query: {} })
}
function openTaskEdit(task: Task) {
selectedTask.value = task
taskDrawerOpen.value = true
if (project.value?.code && task.number) {
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
}
}
watch(taskDrawerOpen, (open) => {
if (!open) {
router.replace({ query: {} })
}
})
function onDragEnter(id: number) {
dragCounter.value++
dragOverStatusId.value = id
@@ -311,6 +402,52 @@ async function onDropBacklog(event: DragEvent) {
await taskService.update(taskId, { status: null })
}
function toggleTaskSelect(taskId: number) {
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId)
} else {
selectedTaskIds.add(taskId)
}
}
function toggleSelectAll(taskList: Task[]) {
if (selectedTaskIds.size === taskList.length) {
selectedTaskIds.clear()
} else {
taskList.forEach(t => selectedTaskIds.add(t.id))
}
}
async function onBulkUpdate(field: string, value: number) {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
const payload: Record<string, unknown> = {}
if (field === 'status') payload.status = `/api/task_statuses/${value}`
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
else if (field === 'group') payload.group = `/api/task_groups/${value}`
await Promise.all(ids.map(id => taskService.update(id, payload)))
selectedTaskIds.clear()
await loadData()
}
async function onBulkArchive() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
selectedTaskIds.clear()
await loadData()
}
async function onBulkDelete() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.remove(id)))
selectedTaskIds.clear()
await loadData()
}
async function onSaved() {
await loadData()
}
@@ -319,7 +456,20 @@ async function onProjectSaved() {
await loadData()
}
onMounted(() => {
loadData()
onMounted(async () => {
await loadData()
const taskParam = route.query.task as string | undefined
if (taskParam && project.value) {
const prefix = `${project.value.code}-`
if (taskParam.startsWith(prefix)) {
const num = Number(taskParam.slice(prefix.length))
if (num) {
const task = tasks.value.find(t => t.number === num)
if (task) {
openTaskEdit(task)
}
}
}
}
})
</script>

View File

@@ -13,26 +13,31 @@
</div>
<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 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">
<div class="flex shrink-0 items-center gap-1 h-8">
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
{{ currentMonthLabel }}
</h2>
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<div class="flex items-center rounded-full bg-neutral-100 p-1">
<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'"
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
:class="viewMode === mode
? 'bg-primary-500 text-white shadow-sm'
: '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="[&>div]:!mt-0">
@@ -71,7 +76,13 @@
/>
</div>
<DateFilter v-model="selectedDateFilter" />
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
</div>
</div>
@@ -294,6 +305,40 @@ async function onDelete(entry: TimeEntry) {
await loadEntries()
}
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
async function loadEntries() {
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
@@ -334,6 +379,7 @@ onMounted(async () => {
})
watch(viewMode, () => {
selectedDateFilter.value = null
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
loadEntries()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,22 @@
export type TaskRecurrence = {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
}
export type TaskRecurrenceWrite = {
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek?: string[] | null
dayOfMonth?: number | null
weekOfMonth?: number | null
endDate?: string | null
maxOccurrences?: number | null
}

View File

@@ -29,6 +29,23 @@ export type Task = {
status: string
title: string
} | null
scheduledStart: string | null
scheduledEnd: string | null
deadline: string | null
syncToCalendar: boolean
calendarSyncError: string | null
recurrence: {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
} | null
}
export type TaskWrite = {
@@ -43,4 +60,9 @@ export type TaskWrite = {
tags: string[]
archived?: boolean
clientTicket?: string | null
scheduledStart?: string | null
scheduledEnd?: string | null
deadline?: string | null
syncToCalendar?: boolean
recurrence?: string | null
}

View File

@@ -0,0 +1,19 @@
export type ZimbraSettings = {
serverUrl: string | null
username: string | null
calendarPath: string | null
enabled: boolean
hasPassword: boolean
}
export type ZimbraSettingsWrite = {
serverUrl: string | null
username: string | null
calendarPath: string | null
password?: string | null
enabled: boolean
}
export type ZimbraTestResult = {
success: boolean
}

View File

@@ -0,0 +1,25 @@
import type { TaskRecurrence, TaskRecurrenceWrite } from './dto/task-recurrence'
export function useTaskRecurrenceService() {
const api = useApi()
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
toastSuccessKey: 'taskRecurrence.created',
})
}
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskRecurrence.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_recurrences/${id}`, {}, {
toastSuccessKey: 'taskRecurrence.deleted',
})
}
return { create, update, remove }
}

View File

@@ -50,5 +50,23 @@ export function useTimeEntryService() {
})
}
return { getByDateRange, getActive, create, update, remove }
function getExportUrl(params: {
after: string
before: string
user?: number
project?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.user) query.set('user', String(params.user))
if (params.project) query.set('project', String(params.project))
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/api/time_entries/export?${query.toString()}`
}
return { getByDateRange, getActive, create, update, remove, getExportUrl }
}

View File

@@ -0,0 +1,21 @@
import type { ZimbraSettings, ZimbraSettingsWrite, ZimbraTestResult } from './dto/zimbra'
export function useZimbraService() {
const api = useApi()
async function getSettings(): Promise<ZimbraSettings> {
return api.get<ZimbraSettings>('/settings/zimbra')
}
async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
toastSuccessKey: 'zimbra.settings.saved',
})
}
async function testConnection(): Promise<ZimbraTestResult> {
return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
}
return { getSettings, saveSettings, testConnection }
}

View File

@@ -1,12 +1,19 @@
export const useUiStore = defineStore('ui', () => {
const sidebarCollapsed = ref(false)
const sidebarOpen = ref(false)
const darkMode = ref(false)
if (import.meta.client) {
const saved = localStorage.getItem('ui-sidebar-collapsed')
if (saved !== null) {
sidebarCollapsed.value = saved === 'true'
}
const savedDark = localStorage.getItem('ui-dark-mode')
if (savedDark !== null) {
darkMode.value = savedDark === 'true'
}
applyDarkClass(darkMode.value)
}
watch(sidebarCollapsed, (val) => {
@@ -15,6 +22,25 @@ export const useUiStore = defineStore('ui', () => {
}
})
watch(darkMode, (val) => {
if (import.meta.client) {
localStorage.setItem('ui-dark-mode', String(val))
applyDarkClass(val)
}
})
function applyDarkClass(dark: boolean) {
if (dark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
function toggleDarkMode() {
darkMode.value = !darkMode.value
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
@@ -27,5 +53,5 @@ export const useUiStore = defineStore('ui', () => {
sidebarOpen.value = false
}
return { sidebarCollapsed, sidebarOpen, toggleSidebar, openMobileSidebar, closeMobileSidebar }
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
})

View File

@@ -1,6 +1,7 @@
import type {Config} from 'tailwindcss'
export default <Partial<Config>>{
darkMode: 'class',
theme: {
extend: {
fontFamily: {

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260319090835 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE task_recurrence (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(255) NOT NULL, interval INT NOT NULL, days_of_week JSON DEFAULT NULL, day_of_month INT DEFAULT NULL, week_of_month INT DEFAULT NULL, end_date DATE DEFAULT NULL, max_occurrences INT DEFAULT NULL, occurrence_count INT NOT NULL, version INT DEFAULT 1 NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE zimbra_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, server_url VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, calendar_path VARCHAR(255) DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY (id))');
$this->addSql('ALTER TABLE task ADD scheduled_start TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD scheduled_end TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD deadline TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD sync_to_calendar BOOLEAN DEFAULT false NOT NULL');
$this->addSql('ALTER TABLE task ADD calendar_event_uid VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD calendar_todo_uid VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD calendar_sync_error TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD recurrence_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB252C414CE8 FOREIGN KEY (recurrence_id) REFERENCES task_recurrence (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_527EDB252C414CE8 ON task (recurrence_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE task_recurrence');
$this->addSql('DROP TABLE zimbra_configuration');
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB252C414CE8');
$this->addSql('DROP INDEX IDX_527EDB252C414CE8');
$this->addSql('ALTER TABLE task DROP scheduled_start');
$this->addSql('ALTER TABLE task DROP scheduled_end');
$this->addSql('ALTER TABLE task DROP deadline');
$this->addSql('ALTER TABLE task DROP sync_to_calendar');
$this->addSql('ALTER TABLE task DROP calendar_event_uid');
$this->addSql('ALTER TABLE task DROP calendar_todo_uid');
$this->addSql('ALTER TABLE task DROP calendar_sync_error');
$this->addSql('ALTER TABLE task DROP recurrence_id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\ZimbraSettingsProcessor;
use App\State\ZimbraSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/zimbra',
normalizationContext: ['groups' => ['zimbra_settings:read']],
provider: ZimbraSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/zimbra',
denormalizationContext: ['groups' => ['zimbra_settings:write']],
normalizationContext: ['groups' => ['zimbra_settings:read']],
provider: ZimbraSettingsProvider::class,
processor: ZimbraSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ZimbraSettings
{
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $serverUrl = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $username = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public ?string $calendarPath = null;
#[Groups(['zimbra_settings:write'])]
public ?string $password = null;
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
public bool $enabled = false;
#[Groups(['zimbra_settings:read'])]
public bool $hasPassword = false;
}

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\ZimbraTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/zimbra/test',
input: false,
normalizationContext: ['groups' => ['zimbra_test:read']],
provider: ZimbraTestConnectionProvider::class,
processor: ZimbraTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ZimbraTestConnection
{
#[Groups(['zimbra_test:read'])]
public bool $success = false;
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): BinaryFileResponse
{
$afterStr = $request->query->getString('after');
$beforeStr = $request->query->getString('before');
if ('' === $afterStr || '' === $beforeStr) {
throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
}
try {
$after = new DateTimeImmutable($afterStr);
$before = new DateTimeImmutable($beforeStr);
} catch (Exception) {
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
// Max range: 12 months
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// Authorization: non-admin users can only export their own data
$user = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $user */
$user = $this->security->getUser();
} else {
$userId = $request->query->getInt('user');
if ($userId > 0) {
$user = $this->entityManager->getRepository(User::class)->find($userId);
}
}
$project = null;
$projectId = $request->query->getInt('project');
if ($projectId > 0) {
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
}
/** @var int[] $tagIds */
$tagIds = array_filter(
array_map('intval', (array) $request->query->all('tags')),
fn (int $id) => $id > 0,
);
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$user,
$project,
$tagIds ?: null,
);
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
}

View File

@@ -11,10 +11,13 @@ use App\Entity\Task;
use App\Entity\TaskEffort;
use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskRecurrence;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use App\Entity\ZimbraConfiguration;
use App\Enum\RecurrenceType;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -274,6 +277,9 @@ class AppFixtures extends Fixture
$task2->setGroup($groupFrontend);
$task2->setProject($projectSirh);
$task2->addTag($tagAuth);
$task2->setScheduledStart(new DateTimeImmutable('next monday 09:00'));
$task2->setScheduledEnd(new DateTimeImmutable('next monday 17:00'));
$task2->setSyncToCalendar(false);
$manager->persist($task2);
$task3 = new Task();
@@ -308,6 +314,8 @@ class AppFixtures extends Fixture
$task5->setAssignee($userCharlie);
$task5->setProject($projectSirh);
$task5->addTag($tagCalendar);
$task5->setDeadline(new DateTimeImmutable('+2 weeks'));
$task5->setSyncToCalendar(false);
$manager->persist($task5);
$task6 = new Task();
@@ -414,6 +422,8 @@ class AppFixtures extends Fixture
$taskErp3->setAssignee($admin);
$taskErp3->setGroup($groupErpFacturation);
$taskErp3->setProject($projectErp);
$taskErp3->setDeadline(new DateTimeImmutable('+1 month'));
$taskErp3->setSyncToCalendar(false);
$manager->persist($taskErp3);
$taskErp4 = new Task();
@@ -650,6 +660,39 @@ class AppFixtures extends Fixture
// Link a task to a client ticket
$task3->setClientTicket($ticket1);
// =============================================
// Zimbra Configuration
// =============================================
$zimbraConfig = new ZimbraConfiguration();
$zimbraConfig->setServerUrl('https://mail.ovh.com');
$zimbraConfig->setUsername('lesstime@ovh.fr');
$zimbraConfig->setCalendarPath('/dav/lesstime@ovh.fr/Calendar/');
$zimbraConfig->setEnabled(false);
$manager->persist($zimbraConfig);
// =============================================
// Task Recurrence — exemple hebdomadaire
// =============================================
$recurrence = new TaskRecurrence();
$recurrence->setType(RecurrenceType::Weekly);
$recurrence->setInterval(1);
$recurrence->setDaysOfWeek(['monday', 'wednesday', 'friday']);
$manager->persist($recurrence);
$taskRecurring = new Task();
$taskRecurring->setNumber(7);
$taskRecurring->setTitle('Réunion de suivi hebdomadaire');
$taskRecurring->setStatus($statusTodo);
$taskRecurring->setEffort($effortS);
$taskRecurring->setPriority($priorityMedium);
$taskRecurring->setAssignee($admin);
$taskRecurring->setProject($projectSirh);
$taskRecurring->setScheduledStart(new DateTimeImmutable('next monday 10:00'));
$taskRecurring->setScheduledEnd(new DateTimeImmutable('next monday 10:30'));
$taskRecurring->setSyncToCalendar(false);
$taskRecurring->setRecurrence($recurrence);
$manager->persist($taskRecurring);
$manager->flush();
}
}

View File

@@ -34,11 +34,11 @@ class Client
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client:read', 'project:read'])]
#[Groups(['client:read', 'project:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['client:read', 'client:write', 'project:read'])]
#[Groups(['client:read', 'client:write', 'project:read', 'user:list'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]

View File

@@ -43,7 +43,7 @@ class Project
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read'])]
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 10, unique: true)]
@@ -53,7 +53,7 @@ class Project
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read'])]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
@@ -14,26 +16,32 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskRepository;
use App\State\TaskCalendarProcessor;
use App\State\TaskNumberProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
],
normalizationContext: ['groups' => ['task:read']],
denormalizationContext: ['groups' => ['task:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
#[ORM\Entity(repositoryClass: TaskRepository::class)]
#[ORM\Table(name: 'task')]
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
@@ -111,6 +119,37 @@ class Task
#[Groups(['task:read', 'task:write'])]
private ?ClientTicket $clientTicket = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $scheduledStart = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $scheduledEnd = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $deadline = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['task:read', 'task:write'])]
private bool $syncToCalendar = false;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarEventUid = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarTodoUid = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['task:read'])]
private ?string $calendarSyncError = null;
#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskRecurrence $recurrence = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -281,4 +320,118 @@ class Task
return $this;
}
public function getScheduledStart(): ?DateTimeImmutable
{
return $this->scheduledStart;
}
public function setScheduledStart(?DateTimeImmutable $scheduledStart): static
{
$this->scheduledStart = $scheduledStart;
return $this;
}
public function getScheduledEnd(): ?DateTimeImmutable
{
return $this->scheduledEnd;
}
public function setScheduledEnd(?DateTimeImmutable $scheduledEnd): static
{
$this->scheduledEnd = $scheduledEnd;
return $this;
}
public function getDeadline(): ?DateTimeImmutable
{
return $this->deadline;
}
public function setDeadline(?DateTimeImmutable $deadline): static
{
$this->deadline = $deadline;
return $this;
}
public function isSyncToCalendar(): bool
{
return $this->syncToCalendar;
}
public function setSyncToCalendar(bool $syncToCalendar): static
{
$this->syncToCalendar = $syncToCalendar;
return $this;
}
public function getCalendarEventUid(): ?string
{
return $this->calendarEventUid;
}
public function setCalendarEventUid(?string $calendarEventUid): static
{
$this->calendarEventUid = $calendarEventUid;
return $this;
}
public function getCalendarTodoUid(): ?string
{
return $this->calendarTodoUid;
}
public function setCalendarTodoUid(?string $calendarTodoUid): static
{
$this->calendarTodoUid = $calendarTodoUid;
return $this;
}
public function getCalendarSyncError(): ?string
{
return $this->calendarSyncError;
}
public function setCalendarSyncError(?string $calendarSyncError): static
{
$this->calendarSyncError = $calendarSyncError;
return $this;
}
public function getRecurrence(): ?TaskRecurrence
{
return $this->recurrence;
}
public function setRecurrence(?TaskRecurrence $recurrence): static
{
$this->recurrence = $recurrence;
return $this;
}
#[Assert\Callback]
public function validateScheduledDates(ExecutionContextInterface $context): void
{
if ((null === $this->scheduledStart) !== (null === $this->scheduledEnd)) {
$context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.')
->atPath('scheduledEnd')
->addViolation()
;
}
if (null !== $this->scheduledStart && null !== $this->scheduledEnd
&& $this->scheduledEnd <= $this->scheduledStart) {
$context->buildViolation('scheduledEnd must be after scheduledStart.')
->atPath('scheduledEnd')
->addViolation()
;
}
}
}

View File

@@ -13,20 +13,21 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\EventListener\TaskDocumentListener;
use App\State\TaskDocumentProcessor;
use App\State\TaskDocumentProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\RecurrenceType;
use App\Repository\TaskRecurrenceRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_recurrence:read']],
denormalizationContext: ['groups' => ['task_recurrence:write']],
)]
#[ORM\Entity(repositoryClass: TaskRecurrenceRepository::class)]
class TaskRecurrence
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_recurrence:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', enumType: RecurrenceType::class)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?RecurrenceType $type = null;
#[ORM\Column(type: 'integer')]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private int $interval = 1;
#[ORM\Column(type: 'json', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?array $daysOfWeek = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $dayOfMonth = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $weekOfMonth = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $maxOccurrences = null;
#[ORM\Column(type: 'integer')]
#[Groups(['task_recurrence:read'])]
private int $occurrenceCount = 0;
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?RecurrenceType
{
return $this->type;
}
public function setType(RecurrenceType $type): static
{
$this->type = $type;
return $this;
}
public function getInterval(): int
{
return $this->interval;
}
public function setInterval(int $interval): static
{
$this->interval = $interval;
return $this;
}
public function getDaysOfWeek(): ?array
{
return $this->daysOfWeek;
}
public function setDaysOfWeek(?array $daysOfWeek): static
{
$this->daysOfWeek = $daysOfWeek;
return $this;
}
public function getDayOfMonth(): ?int
{
return $this->dayOfMonth;
}
public function setDayOfMonth(?int $dayOfMonth): static
{
$this->dayOfMonth = $dayOfMonth;
return $this;
}
public function getWeekOfMonth(): ?int
{
return $this->weekOfMonth;
}
public function setWeekOfMonth(?int $weekOfMonth): static
{
$this->weekOfMonth = $weekOfMonth;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getMaxOccurrences(): ?int
{
return $this->maxOccurrences;
}
public function setMaxOccurrences(?int $maxOccurrences): static
{
$this->maxOccurrences = $maxOccurrences;
return $this;
}
public function getOccurrenceCount(): int
{
return $this->occurrenceCount;
}
public function getVersion(): int
{
return $this->version;
}
/** @return Collection<int, Task> */
public function getTasks(): Collection
{
return $this->tasks;
}
public function incrementOccurrenceCount(): static
{
++$this->occurrenceCount;
return $this;
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ZimbraConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ZimbraConfigurationRepository::class)]
class ZimbraConfiguration
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $serverUrl = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedPassword = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarPath = null;
#[ORM\Column(type: 'boolean')]
private bool $enabled = false;
public function getId(): ?int
{
return $this->id;
}
public function getServerUrl(): ?string
{
return $this->serverUrl;
}
public function setServerUrl(?string $serverUrl): static
{
$this->serverUrl = $serverUrl;
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
public function getEncryptedPassword(): ?string
{
return $this->encryptedPassword;
}
public function setEncryptedPassword(?string $encryptedPassword): static
{
$this->encryptedPassword = $encryptedPassword;
return $this;
}
public function getCalendarPath(): ?string
{
return $this->calendarPath;
}
public function setCalendarPath(?string $calendarPath): static
{
$this->calendarPath = $calendarPath;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function hasPassword(): bool
{
return null !== $this->encryptedPassword;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum RecurrenceType: string
{
case Daily = 'daily';
case Weekly = 'weekly';
case Monthly = 'monthly';
case Yearly = 'yearly';
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use App\Repository\TaskRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array (e.g. ["monday","wednesday"]). For monthly, provide dayOfMonth OR weekOfMonth.')]
class CreateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $taskId,
string $type,
int $interval = 1,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$recurrenceType = RecurrenceType::from($type);
$recurrence = new TaskRecurrence();
$recurrence->setType($recurrenceType);
$recurrence->setInterval($interval);
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$task->setRecurrence($recurrence);
$this->entityManager->persist($recurrence);
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
'taskId' => $task->getId(),
]);
}
}

View File

@@ -14,6 +14,8 @@ use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -36,6 +38,7 @@ class CreateTaskTool
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
@@ -48,6 +51,10 @@ class CreateTaskTool
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -109,6 +116,18 @@ class CreateTaskTool
$task->addTag($tag);
}
}
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
@@ -116,19 +135,26 @@ class CreateTaskTool
$this->entityManager->flush();
});
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRecurrenceRepository;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-recurrence', description: 'Delete a task recurrence pattern. Nullifies the recurrence on the active task and removes the recurring calendar event.')]
class DeleteTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(int $recurrenceId): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
$tasks = $recurrence->getTasks()->toArray();
$eventUidToDelete = null;
foreach ($tasks as $task) {
if (null !== $task->getCalendarEventUid()) {
$eventUidToDelete = $task->getCalendarEventUid();
break;
}
}
foreach ($tasks as $task) {
$task->setRecurrence(null);
}
$this->entityManager->remove($recurrence);
$this->entityManager->flush();
if (null !== $eventUidToDelete) {
$this->calDavService->deleteEvent($eventUidToDelete);
}
return json_encode([
'success' => true,
'message' => sprintf('TaskRecurrence %d deleted.', $recurrenceId),
'tasksUpdated' => count($tasks),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -20,6 +21,7 @@ class DeleteTaskTool
private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(int $id): string
@@ -35,9 +37,18 @@ class DeleteTaskTool
}
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
$eventUid = $task->getCalendarEventUid();
$todoUid = $task->getCalendarTodoUid();
$this->entityManager->remove($task);
$this->entityManager->flush();
if (null !== $eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if (null !== $todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return json_encode([
'success' => true,
'message' => sprintf('Task %s deleted.', $taskCode),

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Enum\RecurrenceType;
use App\Repository\TaskRecurrenceRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-task-recurrence', description: 'Update an existing task recurrence pattern.')]
class UpdateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepository $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $recurrenceId,
?string $type = null,
?int $interval = null,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->find($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
if (null !== $type) {
$recurrence->setType(RecurrenceType::from($type));
}
if (null !== $interval) {
$recurrence->setInterval($interval);
}
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$this->entityManager->flush();
foreach ($recurrence->getTasks() as $task) {
$this->calDavService->syncTask($task);
}
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
]);
}
}

View File

@@ -12,6 +12,8 @@ use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -33,6 +35,7 @@ class UpdateTaskTool
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
@@ -46,6 +49,10 @@ class UpdateTaskTool
?int $groupId = null,
?array $tagIds = null,
?bool $archived = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -114,22 +121,40 @@ class UpdateTaskTool
if (null !== $archived) {
$task->setArchived($archived);
}
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]);
}
}

View File

@@ -40,7 +40,7 @@ class NotificationRepository extends ServiceEntityRepository
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->executeStatement()
->execute()
;
}
}

View File

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

View File

@@ -14,4 +14,16 @@ class TaskStatusRepository extends ServiceEntityRepository
{
parent::__construct($registry, TaskStatus::class);
}
public function findFirstNonFinal(): ?TaskStatus
{
return $this->createQueryBuilder('s')
->where('s.isFinal = :final')
->setParameter('final', false)
->orderBy('s.position', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use App\Entity\TimeEntry;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -26,4 +28,46 @@ class TimeEntryRepository extends ServiceEntityRepository
'stoppedAt' => null,
]);
}
/**
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?User $user = null,
?Project $project = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
->andWhere('te.startedAt >= :after')
->andWhere('te.startedAt < :before')
->setParameter('after', $after)
->setParameter('before', $before)
->orderBy('te.startedAt', 'ASC')
;
if (null !== $user) {
$qb->andWhere('te.user = :user')
->setParameter('user', $user)
;
}
if (null !== $project) {
$qb->andWhere('te.project = :project')
->setParameter('project', $project)
;
}
if (null !== $tagIds && [] !== $tagIds) {
$qb->join('te.tags', 'tag')
->andWhere('tag.id IN (:tagIds)')
->setParameter('tagIds', $tagIds)
;
}
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ZimbraConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ZimbraConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ZimbraConfiguration::class);
}
public function findSingleton(): ?ZimbraConfiguration
{
return $this->createQueryBuilder('z')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use App\Repository\ZimbraConfigurationRepository;
use DateTimeZone;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class CalDavService
{
public function __construct(
private readonly ZimbraConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {}
public function isConfigured(): bool
{
$config = $this->configRepository->findSingleton();
return null !== $config && $config->isEnabled();
}
public function testConnection(): bool
{
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isEnabled()) {
return false;
}
try {
$response = $this->httpClient->request('PROPFIND', $this->getCalendarUrl(), [
'timeout' => 5,
'auth_basic' => [
$config->getUsername(),
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
],
'headers' => [
'Depth' => '0',
],
]);
$statusCode = $response->getStatusCode();
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
} catch (Throwable $e) {
$this->logger->error('CalDAV connection test failed: '.$e->getMessage());
return false;
}
}
public function createEvent(Task $task): ?string
{
$uid = $this->generateUid();
$calendar = $this->buildEventCalendar($task, $uid);
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
return null;
}
return $uid;
}
public function createTodo(Task $task): ?string
{
$uid = $this->generateUid();
$calendar = $this->buildTodoCalendar($task, $uid);
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
return null;
}
return $uid;
}
public function updateEvent(Task $task): bool
{
$uid = $task->getCalendarEventUid();
if (null === $uid) {
return false;
}
$calendar = $this->buildEventCalendar($task, $uid);
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
}
public function updateTodo(Task $task): bool
{
$uid = $task->getCalendarTodoUid();
if (null === $uid) {
return false;
}
$calendar = $this->buildTodoCalendar($task, $uid);
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
}
public function deleteEvent(?string $uid): bool
{
if (null === $uid) {
return true;
}
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
}
public function deleteTodo(?string $uid): bool
{
if (null === $uid) {
return true;
}
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
}
public function syncTask(Task $task): void
{
if (!$task->isSyncToCalendar()) {
$this->deleteEvent($task->getCalendarEventUid());
$this->deleteTodo($task->getCalendarTodoUid());
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
$task->setCalendarSyncError(null);
return;
}
$hasStart = null !== $task->getScheduledStart();
$hasDeadline = null !== $task->getDeadline();
if (!$hasStart && !$hasDeadline) {
return;
}
$syncError = null;
if ($hasStart) {
if (null !== $task->getCalendarEventUid()) {
$success = $this->updateEvent($task);
} else {
$uid = $this->createEvent($task);
if (null !== $uid) {
$task->setCalendarEventUid($uid);
$success = true;
} else {
$success = false;
}
}
if (!$success) {
$syncError = 'Failed to sync event to calendar.';
}
} elseif (null !== $task->getCalendarEventUid()) {
$this->deleteEvent($task->getCalendarEventUid());
$task->setCalendarEventUid(null);
}
if ($hasDeadline) {
if (null !== $task->getCalendarTodoUid()) {
$success = $this->updateTodo($task);
} else {
$uid = $this->createTodo($task);
if (null !== $uid) {
$task->setCalendarTodoUid($uid);
$success = true;
} else {
$success = false;
}
}
if (!$success) {
$syncError = ($syncError ?? '').'Failed to sync todo to calendar.';
}
} elseif (null !== $task->getCalendarTodoUid()) {
$this->deleteTodo($task->getCalendarTodoUid());
$task->setCalendarTodoUid(null);
}
$task->setCalendarSyncError($syncError);
}
private function buildEventCalendar(Task $task, string $uid): VCalendar
{
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VEVENT', [
'UID' => $uid,
'SUMMARY' => $summary,
'DTSTART' => $task->getScheduledStart(),
'DTEND' => $task->getScheduledEnd(),
'DESCRIPTION' => $description,
]);
$recurrence = $task->getRecurrence();
if (null !== $recurrence) {
$vevent = $vcalendar->VEVENT;
$vevent->add('RRULE', $this->buildRRule($recurrence));
}
return $vcalendar;
}
private function buildTodoCalendar(Task $task, string $uid): VCalendar
{
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VTODO', [
'UID' => $uid,
'SUMMARY' => $summary,
'DUE' => $task->getDeadline(),
'DESCRIPTION' => $description,
]);
return $vcalendar;
}
private function buildRRule(TaskRecurrence $recurrence): string
{
$parts = [];
$interval = $recurrence->getInterval();
match ($recurrence->getType()) {
RecurrenceType::Daily => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
RecurrenceType::Weekly => (function () use (&$parts, $interval, $recurrence): void {
$dayMap = $this->getDayMap();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
$byDay = implode(',', array_map(fn (string $d) => $dayMap[$d] ?? $d, $daysOfWeek));
$rule = 'FREQ=WEEKLY;INTERVAL='.$interval;
if ('' !== $byDay) {
$rule .= ';BYDAY='.$byDay;
}
$parts[] = $rule;
})(),
RecurrenceType::Monthly => (function () use (&$parts, $interval, $recurrence): void {
$dayOfMonth = $recurrence->getDayOfMonth();
$weekOfMonth = $recurrence->getWeekOfMonth();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
if (null !== $dayOfMonth) {
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYMONTHDAY='.$dayOfMonth;
} elseif (null !== $weekOfMonth && [] !== $daysOfWeek) {
$dayMap = $this->getDayMap();
$day = $dayMap[$daysOfWeek[0]] ?? $daysOfWeek[0];
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYDAY='.$weekOfMonth.$day;
} else {
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval;
}
})(),
RecurrenceType::Yearly => $parts[] = 'FREQ=YEARLY;INTERVAL='.$interval,
default => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
};
$rule = $parts[0] ?? 'FREQ=DAILY;INTERVAL=1';
$endDate = $recurrence->getEndDate();
$maxOccurrences = $recurrence->getMaxOccurrences();
if (null !== $endDate) {
$rule .= ';UNTIL='.$endDate->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z');
} elseif (null !== $maxOccurrences) {
$rule .= ';COUNT='.$maxOccurrences;
}
return $rule;
}
private function getCalendarUrl(): string
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
return '';
}
return rtrim((string) $config->getServerUrl(), '/').'/'.ltrim((string) $config->getCalendarPath(), '/').'/';
}
private function makeRequest(string $method, string $url, ?string $body = null, string $contentType = 'text/calendar'): bool
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
return false;
}
try {
$options = [
'timeout' => 5,
'auth_basic' => [
$config->getUsername(),
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
],
];
if (null !== $body) {
$options['headers'] = ['Content-Type' => $contentType];
$options['body'] = $body;
}
$response = $this->httpClient->request($method, $url, $options);
$statusCode = $response->getStatusCode();
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
} catch (Throwable $e) {
$this->logger->error(sprintf('CalDAV %s request to %s failed: %s', $method, $url, $e->getMessage()));
return false;
}
}
private function generateUid(): string
{
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
}
/** @return array<string, string> */
private function getDayMap(): array
{
return [
'monday' => 'MO',
'tuesday' => 'TU',
'wednesday' => 'WE',
'thursday' => 'TH',
'friday' => 'FR',
'saturday' => 'SA',
'sunday' => 'SU',
];
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use DateTimeImmutable;
final class RecurrenceCalculator
{
public function getNextDate(Task $task): ?DateTimeImmutable
{
$recurrence = $task->getRecurrence();
$scheduledStart = $task->getScheduledStart();
if (null === $recurrence || null === $scheduledStart) {
return null;
}
if ($this->hasReachedEnd($recurrence)) {
return null;
}
$type = $recurrence->getType();
$interval = $recurrence->getInterval();
return match ($type) {
RecurrenceType::Daily => $this->nextDaily($scheduledStart, $interval),
RecurrenceType::Weekly => $this->nextWeekly($scheduledStart, $interval, $recurrence->getDaysOfWeek() ?? []),
RecurrenceType::Monthly => $this->nextMonthly($scheduledStart, $interval, $recurrence),
RecurrenceType::Yearly => $this->nextYearly($scheduledStart, $interval),
default => null,
};
}
public function getNextEnd(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
{
$scheduledStart = $task->getScheduledStart();
$scheduledEnd = $task->getScheduledEnd();
if (null === $scheduledEnd || null === $scheduledStart) {
return null;
}
$duration = $scheduledStart->diff($scheduledEnd);
return $nextStart->add($duration);
}
public function getNextDeadline(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
{
$scheduledStart = $task->getScheduledStart();
$deadline = $task->getDeadline();
if (null === $deadline || null === $scheduledStart) {
return null;
}
$offset = $scheduledStart->diff($deadline);
return $nextStart->add($offset);
}
public function hasReachedEnd(TaskRecurrence $recurrence): bool
{
$maxOccurrences = $recurrence->getMaxOccurrences();
if (null !== $maxOccurrences && $recurrence->getOccurrenceCount() >= $maxOccurrences) {
return true;
}
$endDate = $recurrence->getEndDate();
if (null !== $endDate) {
$today = new DateTimeImmutable('today');
if ($endDate < $today) {
return true;
}
}
return false;
}
private function nextDaily(DateTimeImmutable $start, int $interval): DateTimeImmutable
{
return $start->modify(sprintf('+%d days', $interval));
}
private function nextWeekly(DateTimeImmutable $start, int $interval, array $daysOfWeek): DateTimeImmutable
{
$candidate = $start->modify(sprintf('+%d weeks', $interval));
if ([] === $daysOfWeek) {
return $candidate;
}
$dayNumberMap = $this->getDayNumberMap();
// Collect target day numbers
$targetDayNumbers = [];
foreach ($daysOfWeek as $day) {
if (isset($dayNumberMap[$day])) {
$targetDayNumbers[] = $dayNumberMap[$day];
}
}
if ([] === $targetDayNumbers) {
return $candidate;
}
sort($targetDayNumbers);
// Find the first matching day in the week starting from candidate
$weekStart = (int) $candidate->format('N'); // 1=Mon, 7=Sun
$candidateDayNum = $weekStart;
foreach ($targetDayNumbers as $targetDay) {
if ($targetDay >= $candidateDayNum) {
$diff = $targetDay - $candidateDayNum;
return $candidate->modify(sprintf('+%d days', $diff));
}
}
// Wrap to next week's first matching day
$diff = 7 - $candidateDayNum + $targetDayNumbers[0];
return $candidate->modify(sprintf('+%d days', $diff));
}
private function nextMonthly(DateTimeImmutable $start, int $interval, TaskRecurrence $recurrence): DateTimeImmutable
{
$dayOfMonth = $recurrence->getDayOfMonth();
$weekOfMonth = $recurrence->getWeekOfMonth();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
if (null !== $dayOfMonth) {
return $this->nextMonthlyByDayOfMonth($start, $interval, $dayOfMonth);
}
if (null !== $weekOfMonth && [] !== $daysOfWeek) {
return $this->nextMonthlyByWeekOfMonth($start, $interval, $weekOfMonth, $daysOfWeek[0]);
}
// Fallback: same day of month, interval months ahead
return $this->nextMonthlyByDayOfMonth($start, $interval, (int) $start->format('j'));
}
private function nextMonthlyByDayOfMonth(DateTimeImmutable $start, int $interval, int $dayOfMonth): DateTimeImmutable
{
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$month += $interval;
while ($month > 12) {
$month -= 12;
++$year;
}
// Handle month overflow (e.g. dayOfMonth=31 in a 30-day month)
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
$day = min($dayOfMonth, $daysInMonth);
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$day,
$start->format('H:i:s'),
));
}
private function nextMonthlyByWeekOfMonth(DateTimeImmutable $start, int $interval, int $weekOfMonth, string $dayName): DateTimeImmutable
{
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$month += $interval;
while ($month > 12) {
$month -= 12;
++$year;
}
$dayNumberMap = $this->getDayNumberMap();
$targetDayNum = $dayNumberMap[$dayName] ?? 1;
// Find the Nth occurrence of the target weekday in the target month
$firstOfMonth = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
$firstDayNum = (int) $firstOfMonth->format('N'); // 1=Mon, 7=Sun
// Days until first occurrence of target weekday
$daysToFirst = ($targetDayNum - $firstDayNum + 7) % 7;
$dayOfMonth = 1 + $daysToFirst + ($weekOfMonth - 1) * 7;
// Handle overflow (e.g. 5th occurrence that doesn't exist)
$daysInMonth = (int) $firstOfMonth->format('t');
if ($dayOfMonth > $daysInMonth) {
// Fall back to last occurrence
$dayOfMonth -= 7;
}
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$dayOfMonth,
$start->format('H:i:s'),
));
}
private function nextYearly(DateTimeImmutable $start, int $interval): DateTimeImmutable
{
$year = (int) $start->format('Y') + $interval;
$month = (int) $start->format('n');
$day = (int) $start->format('j');
// Handle leap year: Feb 29 → Feb 28
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
$day = min($day, $daysInMonth);
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$day,
$start->format('H:i:s'),
));
}
/** @return array<string, int> */
private function getDayNumberMap(): array
{
return [
'monday' => 1,
'tuesday' => 2,
'wednesday' => 3,
'thursday' => 4,
'friday' => 5,
'saturday' => 6,
'sunday' => 7,
];
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\TimeEntry;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use function count;
class TimeEntryExportService
{
private const array DETAIL_HEADERS = [
'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
];
private const array MONTH_NAMES = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
/**
* @param TimeEntry[] $timeEntries
*
* @return string Path to the generated temp file
*/
public function generate(array $timeEntries, DateTimeImmutable $from, DateTimeImmutable $to): string
{
$spreadsheet = new Spreadsheet();
$this->buildDetailSheet($spreadsheet, $timeEntries);
$this->buildProjectRecapSheet($spreadsheet, $timeEntries);
$this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to);
$spreadsheet->setActiveSheetIndex(0);
$tempFile = tempnam(sys_get_temp_dir(), 'export_temps_').'.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
return $tempFile;
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Détail');
// Headers
foreach (self::DETAIL_HEADERS as $col => $header) {
$colLetter = Coordinate::stringFromColumnIndex($col + 1);
$sheet->setCellValue("{$colLetter}1", $header);
}
$this->boldRow($sheet, 1, count(self::DETAIL_HEADERS));
// Data rows
$row = 2;
foreach ($timeEntries as $entry) {
$duration = $this->computeDuration($entry);
$task = $entry->getTask();
$taskLabel = '';
if (null !== $task) {
$project = $task->getProject();
$code = $project?->getCode() ?? '';
$taskLabel = $code.'-'.$task->getNumber().' - '.$task->getTitle();
}
$tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray();
$sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d'));
$sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? '');
$sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? '');
$sheet->setCellValue("D{$row}", $taskLabel);
$sheet->setCellValue("E{$row}", $entry->getTitle() ?? '');
$sheet->setCellValue("F{$row}", implode(', ', $tagLabels));
$sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i'));
$sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? '');
$sheet->setCellValue("I{$row}", round($duration, 2));
$sheet->setCellValue("J{$row}", $entry->getDescription() ?? '');
++$row;
}
// Total row
if ($row > 2) {
$sheet->setCellValue("H{$row}", 'Total');
$sheet->getStyle("H{$row}")->getFont()->setBold(true);
$sheet->setCellValue("I{$row}", '=SUM(I2:I'.($row - 1).')');
$sheet->getStyle("I{$row}")->getFont()->setBold(true);
}
// Auto-size columns
foreach (range('A', 'J') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par projet');
// Aggregate: user → project → hours
$data = [];
$projects = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$projectName = $entry->getProject()?->getName() ?? 'Sans projet';
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$projects[$projectName] = true;
$data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration;
}
ksort($users);
ksort($projects);
$projectList = array_keys($projects);
$userList = array_keys($users);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($projectList as $project) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $project);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($projectList as $project) {
$val = round($data[$user][$project] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($projectList as $project) {
$projectTotal = 0;
foreach ($userList as $user) {
$projectTotal += $data[$user][$project] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
// Grand total
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
/**
* @param TimeEntry[] $timeEntries
*/
private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, DateTimeImmutable $from, DateTimeImmutable $to): void
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Récap par mois');
// Build month columns from the date range
$months = [];
$current = $from->modify('first day of this month');
$end = $to->modify('first day of this month');
while ($current <= $end) {
$key = $current->format('Y-m');
$label = self::MONTH_NAMES[(int) $current->format('n')].' '.$current->format('Y');
$months[$key] = $label;
$current = $current->modify('+1 month');
}
// Aggregate: user → month-key → hours
$data = [];
$users = [];
foreach ($timeEntries as $entry) {
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
$monthKey = $entry->getStartedAt()->format('Y-m');
$duration = $this->computeDuration($entry);
$users[$userName] = true;
$data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration;
}
ksort($users);
$userList = array_keys($users);
$monthKeys = array_keys($months);
// Headers
$sheet->setCellValue('A1', 'Utilisateur');
$col = 2;
foreach ($months as $label) {
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}1", $label);
++$col;
}
$totalLetter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$totalLetter}1", 'Total');
$this->boldRow($sheet, 1, $col);
// Data rows
$row = 2;
foreach ($userList as $user) {
$sheet->setCellValue("A{$row}", $user);
$col = 2;
$userTotal = 0;
foreach ($monthKeys as $monthKey) {
$val = round($data[$user][$monthKey] ?? 0, 2);
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", $val);
$userTotal += $val;
++$col;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$row;
}
// Total row
$sheet->setCellValue("A{$row}", 'Total');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$col = 2;
foreach ($monthKeys as $monthKey) {
$monthTotal = 0;
foreach ($userList as $user) {
$monthTotal += $data[$user][$monthKey] ?? 0;
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
++$col;
}
$grandTotal = 0;
foreach ($data as $userData) {
foreach ($userData as $hours) {
$grandTotal += $hours;
}
}
$letter = Coordinate::stringFromColumnIndex($col);
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
// Auto-size
for ($c = 1; $c <= $col; ++$c) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
}
}
private function computeDuration(TimeEntry $entry): float
{
$start = $entry->getStartedAt();
$end = $entry->getStoppedAt();
if (null === $start || null === $end) {
return 0;
}
return ($end->getTimestamp() - $start->getTimestamp()) / 3600;
}
private function boldRow(Worksheet $sheet, int $row, int $colCount): void
{
for ($c = 1; $c <= $colCount; ++$c) {
$letter = Coordinate::stringFromColumnIndex($c);
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\State;
use App\Entity\Task;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Service\CalDavService;
use App\Service\RecurrenceCalculator;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RecurrenceHandler
{
public function __construct(
private RecurrenceCalculator $calculator,
private TaskRepository $taskRepository,
private TaskStatusRepository $statusRepository,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
) {}
public function handleIfNeeded(Task $task, bool $wasAlreadyFinal): void
{
// Only trigger on STATUS CHANGE to isFinal
$currentStatus = $task->getStatus();
$isNowFinal = $currentStatus?->getIsFinal() ?? false;
if (!$isNowFinal || $wasAlreadyFinal) {
return; // No transition to final
}
$recurrence = $task->getRecurrence();
if (null === $recurrence) {
return; // Not a recurring task
}
if ($this->calculator->hasReachedEnd($recurrence)) {
return; // Recurrence is done
}
$nextStart = $this->calculator->getNextDate($task);
if (null === $nextStart) {
return;
}
// Archive current task, clear calendar UIDs
$savedEventUid = $task->getCalendarEventUid();
$task->setArchived(true);
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
// Create new task with same fields
$newTask = new Task();
$newTask->setProject($task->getProject());
$newTask->setTitle($task->getTitle());
$newTask->setDescription($task->getDescription());
$newTask->setAssignee($task->getAssignee());
$newTask->setEffort($task->getEffort());
$newTask->setPriority($task->getPriority());
$newTask->setGroup($task->getGroup());
$newTask->setRecurrence($recurrence);
$newTask->setSyncToCalendar($task->isSyncToCalendar());
// Copy tags
foreach ($task->getTags() as $tag) {
$newTask->addTag($tag);
}
// Set first non-final status
$firstStatus = $this->statusRepository->findFirstNonFinal();
$newTask->setStatus($firstStatus);
// Set recalculated dates
$newTask->setScheduledStart($nextStart);
$newTask->setScheduledEnd($this->calculator->getNextEnd($task, $nextStart));
$newTask->setDeadline($this->calculator->getNextDeadline($task, $nextStart));
// Copy calendar event UID (recurring VEVENT is shared)
$newTask->setCalendarEventUid($savedEventUid);
// Generate task number in transaction
$this->entityManager->wrapInTransaction(function () use ($newTask): void {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject());
$newTask->setNumber($maxNumber + 1);
$this->entityManager->persist($newTask);
$this->entityManager->flush();
});
// Increment occurrence count (with optimistic locking via @Version)
$recurrence->incrementOccurrenceCount();
$this->entityManager->flush();
// Sync new task's VTODO (new deadline) to Zimbra
if ($newTask->isSyncToCalendar() && $newTask->getDeadline()) {
$uid = $this->calDavService->createTodo($newTask);
if ($uid) {
$newTask->setCalendarTodoUid($uid);
$newTask->setCalendarSyncError(null);
$this->entityManager->flush();
}
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Entity\TaskStatus;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskCalendarProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
* @param ProcessorInterface<Task, Task> $removeProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
private RecurrenceHandler $recurrenceHandler,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Delete) {
$eventUid = $data->getCalendarEventUid();
$todoUid = $data->getCalendarTodoUid();
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
if ($eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if ($todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return $result;
}
// Detect isFinal transition using Doctrine UnitOfWork.
// $data already has the NEW values (API Platform deserialized the PATCH).
// UnitOfWork originalEntityData stores the DB snapshot with entity references for relations.
$uow = $this->entityManager->getUnitOfWork();
$originalData = $uow->getOriginalEntityData($data);
$wasAlreadyFinal = false;
if (isset($originalData['status']) && $originalData['status'] instanceof TaskStatus) {
$wasAlreadyFinal = $originalData['status']->getIsFinal();
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Sync to Zimbra after DB flush
$this->calDavService->syncTask($data);
$this->entityManager->flush();
// Check for recurrence auto-creation (only on STATUS CHANGE to isFinal)
$this->recurrenceHandler->handleIfNeeded($data, $wasAlreadyFinal);
return $result;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\TaskDocument;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|TaskDocument|null
{
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(TaskDocument::class);
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
// Single item
if (isset($uriVariables['id'])) {
$document = $repo->find($uriVariables['id']);
if (null === $document) {
return null;
}
if ($isClient) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $user) {
return null;
}
}
return $document;
}
// Collection
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
if ($isClient) {
$qb->innerJoin('d.clientTicket', 'ct')
->andWhere('ct.submittedBy = :user')
->setParameter('user', $user)
;
}
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
if (isset($filters['clientTicket'])) {
$qb->andWhere('d.clientTicket = :clientTicket')
->setParameter('clientTicket', self::extractId($filters['clientTicket']))
;
}
return $qb->getQuery()->getResult();
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Repository\TaskRepository;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -25,6 +26,7 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
private ProcessorInterface $persistProcessor,
private TaskRepository $taskRepository,
private EntityManagerInterface $entityManager,
private CalDavService $calDavService,
) {}
/**
@@ -33,12 +35,17 @@ final readonly class TaskNumberProcessor implements ProcessorInterface
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
return $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
});
$this->calDavService->syncTask($data);
$this->entityManager->flush();
return $result;
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ZimbraSettings;
use App\Entity\ZimbraConfiguration;
use App\Repository\ZimbraConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ZimbraSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private ZimbraConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
{
assert($data instanceof ZimbraSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new ZimbraConfiguration();
}
$config->setServerUrl($data->serverUrl);
$config->setUsername($data->username);
$config->setCalendarPath($data->calendarPath);
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new ZimbraSettings();
$result->serverUrl = $config->getServerUrl();
$result->username = $config->getUsername();
$result->calendarPath = $config->getCalendarPath();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ZimbraSettings;
use App\Repository\ZimbraConfigurationRepository;
final readonly class ZimbraSettingsProvider implements ProviderInterface
{
public function __construct(
private ZimbraConfigurationRepository $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraSettings
{
$config = $this->configRepository->findSingleton();
$dto = new ZimbraSettings();
if (null !== $config) {
$dto->serverUrl = $config->getServerUrl();
$dto->username = $config->getUsername();
$dto->calendarPath = $config->getCalendarPath();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ZimbraTestConnection;
use App\Service\CalDavService;
use Throwable;
final readonly class ZimbraTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private CalDavService $calDavService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
{
return new ZimbraTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ZimbraTestConnection
{
$result = new ZimbraTestConnection();
try {
$result->success = $this->calDavService->testConnection();
} catch (Throwable) {
$result->success = false;
}
return $result;
}
}