Compare commits

...

100 Commits

Author SHA1 Message Date
gitea-actions
d7968af525 chore: bump version to v0.3.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m49s
2026-03-25 17:42:21 +00:00
df2a48c20d fix : remove double /api prefix in export URL
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
7f1c02256b fix : replace MalioButton with styled native button in export drawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
fdc9b8b60d fix : use correct useToast() API in export handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
1025fed0d1 feat : integrate export drawer with async background download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
0331d94ca5 feat : add TimeTrackingExportDrawer component with filters and period presets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
755c39a0f6 feat : extend export endpoint for multi-user, multi-project, client filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
8f8eeddd91 feat : add downloadExport async method to time-entries service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
548b101d82 feat : add i18n keys for export modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
Matthieu
e3149f8a27 chore : bump version to v0.3.10 and add push-tickets-lesstime skill
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:36:54 +01:00
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
gitea-actions
4074457499 chore: bump version to v0.2.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-18 10:08:03 +00:00
Matthieu
b29b4d304d fix(user) : clear allowedProjects when removing ROLE_CLIENT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Prevents sending /api/projects/undefined when saving a user after
removing client role. Also auto-clears client and projects when
ROLE_CLIENT checkbox is unchecked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:51 +01:00
Matthieu
dd9db93751 feat(project) : add delete button for empty projects with confirmation modal
Adds taskCount virtual field on Project entity, delete button in ProjectDrawer
(visible only when taskCount === 0), and a reusable ConfirmDeleteProjectModal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:41 +01:00
gitea-actions
3e2f3b3cf8 chore: bump version to v0.2.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-17 16:02:42 +00:00
Matthieu
5bf768bc02 feat(ui) : apply pastel project colors on project cards and calendar blocks
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
- Project cards (/projects): 16px radius, pastel background, no border
- Time tracking calendar blocks: pastel opaque background, project color text

Ticket: LST-29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:02:34 +01:00
Matthieu
77c7ceb064 fix(ci) : remove templates/ from release artefact after twig removal
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:38:33 +01:00
Matthieu
ac36eeba36 chore : bump version to 0.2.8
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:36:06 +01:00
gitea-actions
005b731a97 chore: bump version to v0.2.7
Some checks failed
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Failing after 1m14s
2026-03-17 14:27:30 +00:00
Matthieu
3df0b15fe7 docs : update CLAUDE.md with BookStackConfiguration and TaskBookStackLink entities
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m15s
Ticket: T-019

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8040245e45 feat(ui) : make kanban column headers sticky with scrollable content
Give kanban containers a fixed viewport height. Column headers stay fixed
while task cards scroll independently within each column.

Ticket: LST-28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5d378c1f75 refactor(frontend) : replace any types with concrete TypeScript types
Replace 9 occurrences of 'any' with proper types: HydraCollection, Task,
ClientTicketWrite, TimeEntryWrite across 7 components.

Ticket: T-023

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8544babf8c refactor(i18n) : replace hardcoded French strings with i18n keys
Replace 30+ hardcoded strings across 15 components with $t() calls.
Added keys for common actions, drawers titles, empty states, and modals.

Ticket: T-020

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
455121132d feat(frontend) : admin middleware, fix avatar upload, centralize IRI extraction, remove Nitro proxy
- Add admin middleware protecting /admin page (ROLE_ADMIN check)
- Fix useAvatarService to use useApi() with FormData detection
- Create extractIdFromIri() utility, replace manual IRI parsing
- Remove redundant Nitro devProxy (Vite proxy handles dev)

Tickets: T-014, T-015, T-017, T-021

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
fd3097cc26 chore(backend) : rate limiting, cache-control, remove twig, clean deps
- Add login_throttling on /login_check (5 attempts/min) with symfony/rate-limiter
- Add Cache-Control: public, max-age=86400 on avatar responses
- Remove symfony/twig-bundle (unused in API-only project)
- Remove unused dev deps: symfony/browser-kit, symfony/css-selector
- Rename API Platform title to "Lesstime API"

Tickets: T-010, T-016, T-022, T-024, T-025

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ff7cff1d39 fix(backend) : add validation constraints and fix concurrent numbering
- Add Assert\Choice on ClientTicket type and status with typed constants
- Add Assert\Url on GiteaConfiguration, BookStackConfiguration, TaskBookStackLink, ClientTicket
- Fix concurrent task/ticket numbering: use pg_advisory_xact_lock instead of FOR UPDATE with MAX()
- Wrap CreateTaskTool numbering in transaction
- Harmonize repository contracts: both return max number, caller adds +1

Tickets: T-004, T-008, T-011, T-012

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ed58a402b0 fix(auth) : use dedicated plainPassword field for password hashing
- Add non-persisted plainPassword field to User entity (write-only via API)
- Remove direct write access to password field
- Update UserPasswordHasherProcessor to hash from plainPassword
- Update frontend DTO and UserDrawer component

Ticket: T-009

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
2ac815d074 fix(security) : block SVG upload, enforce ROLE_CLIENT restrictions on documents
- Block SVG MIME type in TaskDocumentProcessor upload validation
- Serve existing SVG files as attachment (defense-in-depth) in download controller
- Block ROLE_CLIENT from uploading documents to tasks (only allowed via portal tickets)
- Add Doctrine extension to filter projects by allowedProjects for ROLE_CLIENT

Tickets: T-003, T-005, T-006

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
e0dfcbdbf8 fix(security) : add role checks on Gitea API resources and all MCP tools
- GiteaBranch, GiteaBranchName, GiteaPullRequest: require ROLE_USER
- All 22 MCP tools: require ROLE_USER (ROLE_ADMIN for users/clients listing)

Tickets: T-002, T-007

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5db6b1e2b0 fix(security) : replace real secrets in .env with placeholders and create .env.example
Secrets moved to .env.local (gitignored). Added .env.example for new developers.
Also added .idea/ and docker/.env.docker.local to .gitignore and removed them from tracking.

Tickets: T-001, T-013, T-018

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
gitea-actions
6e29aeb30f chore: bump version to v0.2.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-17 09:38:00 +00:00
Matthieu
cca548dfbc chore : bump version to 0.2.5 and fix MCP session directory
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Move MCP session storage from cache dir to var/mcp-sessions
so it survives cache:clear operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:04 +01:00
Matthieu
3d4b7fad12 fix(mcp) : allow unauthenticated GET on /_mcp for SSE streaming
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Build Release Artefact / build (push) Failing after 1m16s
Claude Code MCP HTTP client sends GET SSE requests without the
Authorization header, breaking the streamable HTTP transport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:15:29 +01:00
Matthieu
5ffb4bbedc chore : bump version to 0.2.3 and add Monolog logging
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m22s
Add symfony/monolog-bundle with rotating file logs in dev (7 days)
and fingers_crossed + rotating file in prod (30 days).
Deploy script now ensures var/log/ permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:52:06 +01:00
Matthieu
d2e9f9ed65 chore : bump version to 0.2.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:08 +01:00
Matthieu
c5898fbf74 feat(ui) : add create task button on my-tasks and responsive kanban columns
- Add "Créer une tâche" button on my-tasks page with mandatory project selector
- TaskModal now accepts optional projects prop for project selection in create mode
- Replace fixed-width kanban columns (w-72 shrink-0) with flexible layout (min-w-36 flex-1)
- Add min-w-0 and overflow-x-hidden on default layout to properly contain content
- Kanban now adapts to screen size from 1024px to 1920px+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:02 +01:00
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
Matthieu
0f99098291 chore : bump version to 0.2.0 and update deploy doc
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m25s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:29:41 +01:00
Matthieu
1c6f473dff feat(mcp) : add clientTicket relation to time entries
Add ManyToOne relation from TimeEntry to ClientTicket entity.
MCP tools create-time-entry, update-time-entry, and list-time-entries
now support clientTicketId parameter for linking tickets to time entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
Matthieu
c95fff530c docs(deploy) : add deployment guide and MCP connection tutorial
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
161 changed files with 10221 additions and 1560 deletions

View File

@@ -0,0 +1,224 @@
---
name: push-tickets-lesstime
description: Use after full-project-review to push TICKETS.md tickets into Lesstime project management via MCP. Triggers on "push tickets", "envoyer tickets", "creer les tickets dans lesstime", "sync tickets lesstime", "pousser les tickets".
---
# Push Tickets to Lesstime
## Overview
Prend le fichier `TICKETS.md` genere par le skill `full-project-review` et cree les taches correspondantes dans Lesstime via son MCP server. Chaque ticket devient une tache avec la bonne priorite, le bon groupe, et la description complete.
## When to Use
- Apres un `full-project-review` qui a genere un `TICKETS.md`
- L'utilisateur demande de "pousser", "sync", "envoyer" les tickets dans Lesstime
- L'utilisateur veut creer les taches dans son gestionnaire de projet
## Prerequis
- Un fichier `TICKETS.md` doit exister dans le repertoire courant (genere par `full-project-review`)
- L'API Lesstime doit etre accessible via HTTP
## Connexion a Lesstime
Lesstime est accessible via un serveur MCP HTTP (JSON-RPC 2.0). Il n'y a PAS de MCP natif configure dans Claude Code — il faut appeler l'API directement via `curl` dans le Bash tool.
### Parametres de connexion
```
URL: http://project.malio-dev.fr/_mcp
TOKEN: 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64
```
### Procedure de connexion (3 etapes)
**Etape 1 — Initialiser la session** (SANS header Mcp-Session-Id) :
```bash
curl -s -D /tmp/mcp_headers -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}' > /dev/null
```
**Etape 2 — Extraire le Session ID** depuis les headers de reponse :
```bash
SID=$(grep -i "mcp-session-id" /tmp/mcp_headers | awk '{print $2}' | tr -d '\r\n')
```
**Etape 3 — Appeler les outils** avec le Session ID :
```bash
curl -s -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}'
```
Les reponses sont au format `{"jsonrpc":"2.0","id":X,"result":{"content":[{"type":"text","text":"[JSON_DATA]"}]}}`.
Extraire les donnees avec : `python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(json.loads(d['result']['content'][0]['text']))"`
### Approche recommandee : script Python
Pour pousser plusieurs tickets, generer un script Python temporaire qui :
1. Initialise la session via curl subprocess
2. Extrait le SID
3. Boucle sur les tickets et appelle create-task pour chacun
4. Affiche le resultat
Voir la memoire `reference_lesstime.md` pour les IDs connus (projets, users, statuts, priorites).
### IDs frequemment utilises
| Type | Label | ID |
|------|-------|----|
| Statut | A faire | 1 |
| Statut | En cours | 2 |
| Statut | Termine | 5 |
| Priorite | Basse | 1 |
| Priorite | Moyen | 2 |
| Priorite | Haute | 3 |
| User | matteo | 6 |
| User | Matthieu | 5 |
| Projet | Infrastructure | 13 |
| Projet | Lesstime | 5 |
| Projet | Inventory | 7 |
| Projet | Ferme | 8 |
| Projet | SIRH | 12 |
**IMPORTANT :** Toujours faire un appel `list-projects` / `list-users` / `list-priorities` en phase Discovery pour verifier que les IDs sont toujours valides. Les IDs ci-dessus sont un cache pour aller plus vite, pas une source de verite.
## Outils MCP Lesstime disponibles
Le MCP Lesstime expose 22 outils. Voici ceux utilises par ce skill :
### Discovery (appeler en premier pour mapper les IDs)
| Outil | Usage |
|-------|-------|
| `list-projects` | Trouver le projectId cible |
| `list-statuses` | Recuperer les statuts disponibles (label, id, color) |
| `list-priorities` | Recuperer les priorites disponibles (label, id, color) |
| `list-efforts` | Recuperer les niveaux d'effort (label, id) |
| `list-groups` | Lister les groupes d'un projet (par projectId) |
| `list-tags` | Lister les tags disponibles (label, id, color) |
| `list-users` | Lister les utilisateurs pour l'assignation |
### Creation
| Outil | Usage |
|-------|-------|
| `create-task` | Creer une tache (projectId, title, description, statusId, priorityId, effortId, assigneeId, groupId, tagIds) |
| `create-group` | Creer un groupe dans un projet (projectId, title) |
### Parametres de `create-task`
```
projectId: int (required) -- ID du projet cible
title: string (required) -- Titre du ticket (ex: "T-001 -- Supprimer le webhook hardcode")
description: string (optional) -- Corps complet du ticket (Pourquoi + A faire + Fichiers)
statusId: int (optional) -- ID du statut initial
priorityId: int (optional) -- ID de la priorite
effortId: int (optional) -- ID de l'effort estime
assigneeId: int (optional) -- ID de l'utilisateur assigne
groupId: int (optional) -- ID du groupe (utilise pour regrouper par priorite)
tagIds: int[] (optional) -- IDs des tags
```
## Process
```dot
digraph push_flow {
rankdir=TB;
"1. Lire TICKETS.md" -> "2. Discovery MCP (parallele)";
"2. Discovery MCP (parallele)" -> "3. Demander projet cible";
"3. Demander projet cible" -> "4. Mapper priorites";
"4. Mapper priorites" -> "5. Creer groupes si besoin";
"5. Creer groupes si besoin" -> "6. Creer les taches";
"6. Creer les taches" -> "7. Resume au user";
}
```
### Phase 1 -- Lire et parser TICKETS.md
Lire le fichier `TICKETS.md` du repertoire courant. Extraire :
- La liste des tickets avec leur ID (T-001, T-002, ...)
- Le titre de chaque ticket
- La priorite (P0, P1, P2, P3) -- derivee de la section dans laquelle se trouve le ticket
- Le corps complet (Pourquoi + A faire + Fichiers) -- sera la description de la tache
**Parsing :**
- Les sections `## P0`, `## P1`, `## P2`, `## P3` delimitent les groupes de priorite
- Chaque `### T-XXX -- {Titre}` est un ticket
- Tout le contenu entre deux `### T-XXX` constitue la description du ticket
### Phase 2 -- Discovery MCP (appels paralleles)
Appeler ces outils MCP **en parallele** pour recuperer les metadonnees :
1. `list-projects` -- pour afficher les projets disponibles
2. `list-statuses` -- pour mapper le statut initial des taches
3. `list-priorities` -- pour mapper P0/P1/P2/P3 aux priorites Lesstime
4. `list-efforts` -- pour estimer l'effort
5. `list-tags` -- pour les tags disponibles
### Phase 3 -- Demander le projet cible
Presenter a l'utilisateur la liste des projets Lesstime et lui demander :
1. **Quel projet ?** -- dans quel projet creer les taches
2. **Quel statut initial ?** -- ex: "To Do", "Backlog"
3. **Creer des groupes par priorite ?** -- ex: "P0 - Urgents", "P1 - Importants"
4. **Assigner a quelqu'un ?** -- optionnel
5. **Tags a ajouter ?** -- ex: "review", "tech-debt"
### Phase 4 -- Mapper les priorites
Mapper les priorites du TICKETS.md aux priorites Lesstime :
- P0 -> priorite la plus haute disponible (ex: "Urgent", "Critical")
- P1 -> priorite haute (ex: "High")
- P2 -> priorite moyenne (ex: "Medium")
- P3 -> priorite basse (ex: "Low")
Si le mapping n'est pas evident, demander confirmation a l'utilisateur.
### Phase 5 -- Creer les groupes (si demande)
Si l'utilisateur veut des groupes par priorite :
1. Creer le groupe "P0 - Urgents (securite)" via `create-group`
2. Creer le groupe "P1 - Importants" via `create-group`
3. Creer le groupe "P2 - Documentation" via `create-group`
4. Creer le groupe "P3 - Nice to have" via `create-group`
### Phase 6 -- Creer les taches
Pour chaque ticket dans TICKETS.md :
1. Construire le titre : `"T-XXX -- {titre}"`
2. Construire la description : le corps complet du ticket (Pourquoi + A faire + Fichiers)
3. Appeler `create-task` avec tous les parametres mappes
**Optimisation :** Creer les taches en parallele par batch de 5 pour eviter de surcharger l'API.
### Phase 7 -- Resume
Afficher un resume au user :
- Nombre de taches creees
- Repartition par priorite
- Lien vers le projet Lesstime (si disponible)
- Taches echouees (si applicable) avec raison
## Mapping par defaut
| TICKETS.md | Lesstime Priority | Lesstime Group |
|------------|-------------------|----------------|
| P0 | Urgent/Critical | "P0 - Urgents (securite)" |
| P1 | High | "P1 - Importants" |
| P2 | Medium | "P2 - Documentation" |
| P3 | Low | "P3 - Nice to have" |
## Common Mistakes
- **Oublier la phase Discovery** -- les IDs de priorites/statuts varient par workspace Lesstime
- **Ne pas demander confirmation** -- toujours valider le projet cible et le mapping avant de creer
- **Creer sans groupes** -- les groupes rendent la vue Lesstime beaucoup plus lisible
- **Description trop courte** -- inclure le corps complet du ticket, pas juste le titre
- **Ne pas gerer les erreurs** -- si une tache echoue, continuer avec les suivantes et reporter a la fin

6
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
APP_SECRET="change_me_in_env_local"
APP_DEBUG=1
DEFAULT_URI=http://localhost/
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
JWT_PASSPHRASE=change_me_in_env_local
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
ENCRYPTION_KEY=aaaaaaaaa
ENCRYPTION_KEY=change_me_in_env_local

99
.env.example Normal file
View File

@@ -0,0 +1,99 @@
###############################################################################
# Lesstime — Fichier d'environnement de reference
#
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
# Les valeurs par defaut dans .env suffisent pour le developpement ;
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
# etre definis dans .env.local.
#
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
###############################################################################
# ===========================================================================
# App
# ===========================================================================
# Environnement Symfony : dev, test, prod
APP_ENV=dev
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
APP_SECRET="change_me_in_env_local"
# Active/desactive le mode debug (1 = oui, 0 = non)
APP_DEBUG=1
# URI par defaut de l'application (utilise pour les liens absolus)
DEFAULT_URI=http://localhost/
# ===========================================================================
# CORS (nelmio/cors-bundle)
# ===========================================================================
# Origines autorisees pour les requetes cross-origin (regex)
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
# ===========================================================================
# JWT (lexik/jwt-authentication-bundle)
# ===========================================================================
# Chemin vers la cle privee RSA pour signer les tokens JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
# Chemin vers la cle publique RSA pour verifier les tokens JWT
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
# Passphrase de la cle privee JWT — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE=change_me_in_env_local
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
JWT_COOKIE_SECURE=0
# Duree de vie du token JWT en secondes (86400 = 24h)
JWT_TOKEN_TTL=86400
# Duree de vie du cookie JWT en secondes (86400 = 24h)
JWT_COOKIE_TTL=86400
# ===========================================================================
# Base de donnees (Doctrine / PostgreSQL)
# ===========================================================================
# Les variables POSTGRES_* sont definies dans docker/.env.docker
# et injectees automatiquement par Docker Compose.
# DATABASE_URL est construite a partir de ces variables.
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
# ===========================================================================
# Chiffrement
# ===========================================================================
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
ENCRYPTION_KEY=change_me_in_env_local
# ===========================================================================
# Docker (docker/.env.docker)
#
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
# surcharger localement.
# ===========================================================================
# DOCKER_APP_NAME=lesstime
# DOCKER_PHP_VERSION=8.4.6
# DOCKER_NODE_VERSION=24.12.0
# APP_USER=www-data
# POSTGRES_DB=lesstime
# POSTGRES_USER=root
# POSTGRES_PASSWORD=root
# POSTGRES_PORT=5435
# XDEBUG_CLIENT_HOST=host.docker.internal
# ===========================================================================
# Frontend (frontend/.env)
# ===========================================================================
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
# NUXT_PUBLIC_API_BASE=/api

View File

@@ -45,12 +45,12 @@ jobs:
set -euo pipefail
mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> ide ###
.idea/
###< ide ###
###> docker local ###
docker/.env.docker.local
###< docker local ###

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Lesstime.iml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="386cba74:19cc24e9181:-799b" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
</modules>
</component>
</project>

20
.idea/php.xml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

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)
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.*",
@@ -26,12 +28,13 @@
"symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
},
@@ -90,8 +93,6 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
"phpunit/phpunit": "^13.0"
}
}

1694
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,11 @@ use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
@@ -24,4 +23,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
];

View File

@@ -1,5 +1,5 @@
api_platform:
title: Hello API Platform
title: Lesstime API
version: 1.0.0
formats:
jsonld: ['application/ld+json']

View File

@@ -19,5 +19,5 @@ mcp:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600

View File

@@ -0,0 +1,56 @@
monolog:
channels:
- deprecation
when@dev:
monolog:
handlers:
main:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 7
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: rotating_file
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
max_files: 7

View File

@@ -22,6 +22,9 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
login_throttling:
max_attempts: 5
interval: '1 minute'
json_login:
check_path: /login_check
username_path: username
@@ -59,6 +62,7 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -1,6 +0,0 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

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.1.2'
app.version: '0.3.11'

View File

@@ -1,5 +1,6 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/frontend/.output/public;
@@ -37,7 +38,6 @@ server {
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
internal;
}
location ~ \.php$ {

View File

@@ -1,9 +0,0 @@
DOCKER_APP_NAME=lesstime
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=lesstime
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5435
XDEBUG_CLIENT_HOST=192.168.0.124

213
docs/deploy.md Normal file
View File

@@ -0,0 +1,213 @@
# Deploiement sur serveur Ubuntu (sans Docker)
## Prerequis
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
- Acces root ou sudo sur le serveur
## 1. Preparer la BDD
```bash
sudo -u postgres createuser lesstime
sudo -u postgres createdb -O lesstime lesstime
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
```
## 2. Creer les dossiers
```bash
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
sudo chown -R www-data:www-data /var/www/lesstime
```
## 3. Configurer l'environnement
```bash
sudo nano /var/www/lesstime/.env
```
Contenu minimal :
```ini
APP_ENV=prod
```
```bash
sudo nano /var/www/lesstime/.env.local
```
Contenu :
```ini
APP_ENV=prod
APP_SECRET=<random-hex-32>
APP_DEBUG=0
DEFAULT_URI=http://project.malio-dev.fr/
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<passphrase>
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
ENCRYPTION_KEY=<random-hex-32>
```
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
## 4. Installer le script de deploy
```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime
```
Si le repo Gitea est prive, configurer un token :
```bash
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
sudo chmod 600 /etc/lesstime-release-token
```
## 5. Deployer une release
```bash
sudo /usr/local/bin/deploy-lesstime v0.2.1
```
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
## 6. Generer les cles JWT
```bash
cd /var/www/lesstime
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
```
## 7. Configurer Nginx
```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 8. Creer le premier user admin
Hasher un mot de passe :
```bash
php /var/www/lesstime/bin/console security:hash-password --env=prod
```
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
```bash
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
```
## 9. Tester
```bash
curl http://project.malio-dev.fr/api/version
curl http://project.malio-dev.fr/
```
---
# Connecter le serveur MCP a Claude Code
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
## 1. Generer un token API
Sur le serveur (ou en local via Docker) :
```bash
# Production (serveur)
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
# Dev (Docker)
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
```
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
## 2. Configurer Claude Code
### Transport HTTP (recommande pour la prod)
Creer ou modifier `.mcp.json` a la racine du projet :
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer <ton-token>"
}
}
}
}
```
### Transport STDIO (dev local via Docker)
```json
{
"mcpServers": {
"lesstime-local": {
"command": "docker",
"args": [
"exec",
"-i",
"php-lesstime-fpm",
"php",
"bin/console",
"mcp:server"
]
}
}
}
```
### Transport STDIO via SSH (prod sans endpoint HTTP)
```json
{
"mcpServers": {
"lesstime": {
"command": "ssh",
"args": [
"user@serveur",
"php",
"/var/www/lesstime/bin/console",
"mcp:server",
"--env=prod"
]
}
}
}
```
## 3. Redemarrer Claude Code
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
## 4. Verifier
Demander a Claude d'utiliser un outil MCP, par exemple :
- "Liste les projets sur Lesstime"
- "Cree une tache dans le projet LT"
## Tools disponibles
| Domaine | Tools |
|---------|-------|
| Projets | list-projects, get-project, create-project, update-project |
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
| Reference | list-users, list-clients |

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

@@ -274,25 +274,22 @@ const availableStatusTransitions = computed(() => {
})
function getProjectName(iri: string): string {
const match = iri.match(/\/api\/projects\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
const id = extractIdFromIri(iri)
if (!id) return ''
return projects.value.find(p => p.id === id)?.name ?? ''
}
function getSubmitterName(iri: string | null): string {
if (!iri) return '-'
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
const id = extractIdFromIri(iri)
if (!id) return ''
return users.value.find(u => u.id === id)?.username ?? ''
}
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return undefined
const id = Number(match[1])
const id = extractIdFromIri(iri)
if (!id) return undefined
return users.value.find(u => u.id === id)
}

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>
@@ -191,7 +192,7 @@
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
@@ -243,7 +244,7 @@ const canEdit = computed(() => {
if (!sub) return false
// submittedBy can be an IRI string or an embedded object
if (typeof sub === 'string') return sub === `/api/users/${userId}`
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
return false
})
@@ -270,7 +271,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as any)
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
isEditing.value = false
emit('refresh')
} finally {

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"
@@ -64,7 +64,7 @@
</div>
</form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting"
@@ -73,7 +73,21 @@
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button>
<button
v-if="project.taskCount === 0"
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
<Icon name="mdi:delete-outline" size="18" />
{{ $t('common.delete') }}
</button>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer>
</template>
@@ -104,6 +118,7 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
}
})
const { create, update } = useProjectService()
const { create, update, remove } = useProjectService()
async function handleSubmit() {
touched.name = true
@@ -213,6 +228,19 @@ async function handleSubmit() {
}
}
async function handleDelete() {
if (!props.project) return
isSubmitting.value = true
try {
await remove(props.project.id)
emit('saved')
isOpen.value = false
} finally {
confirmDeleteOpen.value = false
isSubmitting.value = false
}
}
async function handleArchiveToggle() {
if (!props.project) return
isSubmitting.value = true

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 ? 'Modifier un ticket' : 'Ajouter un ticket'">
<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 any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
}
isSubmitting.value = true
try {
await update(props.task.id, { archived: true })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleUnarchive() {
if (!props.task) return
isSubmitting.value = true
try {
await update(props.task.id, { archived: false })
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
isSubmitting.value = true
try {
const payload: TaskWrite = {
title: form.title.trim(),
description: form.description.trim() || null,
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
if (isEditing.value && props.task) {
await update(props.task.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"

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

@@ -24,7 +24,7 @@
{{ task.project.code }}-{{ task.number }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2>
</div>
<button
@@ -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"
@@ -65,6 +84,20 @@
@blur="touched.title = true"
/>
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect
@@ -142,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>
@@ -182,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
@@ -265,6 +494,9 @@ 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'
const props = defineProps<{
modelValue: boolean
@@ -276,6 +508,7 @@ const props = defineProps<{
tags: TaskTag[]
groups: TaskGroup[]
users: UserData[]
projects?: Project[]
}>()
const emit = defineEmits<{
@@ -296,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()
@@ -318,10 +552,27 @@ const form = reactive({
groupId: null as number | null,
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({
title: false,
project: false,
})
const statusOptions = computed(() =>
@@ -340,8 +591,22 @@ 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 groupOptions = computed(() => {
let filtered = props.groups.filter(g => !g.archived)
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const canArchive = computed(() => {
@@ -364,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 ?? ''
@@ -375,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 = ''
@@ -385,18 +702,41 @@ function populateForm(task: Task | null) {
form.groupId = 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
}
watch(() => props.modelValue, async (open) => {
if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task)
try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
} catch {
const pid = resolvedProjectId.value
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
@@ -419,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[]>([])
@@ -426,6 +767,22 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
)
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -512,7 +869,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
@@ -541,7 +898,9 @@ async function handleUnarchive() {
async function handleSubmit() {
touched.title = true
touched.project = true
if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true
try {
@@ -553,15 +912,45 @@ async function handleSubmit() {
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}`,
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

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="blockEl"
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
:style="blockStyle"
:class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)"
@@ -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 || 'Sans titre' }}</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 || 'Sans titre' }}</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 || 'Sans titre' }}</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,10 +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 bgColor = props.entry.project?.color ?? '#94a3b8'
const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1
@@ -127,13 +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: bgColor,
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

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -105,19 +105,29 @@
>
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>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
@@ -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()
@@ -257,7 +287,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as any)
await create(payload as TimeEntryWrite)
}
emit('saved')

View File

@@ -1,13 +1,13 @@
<template>
<div class="space-y-2">
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
Aucune activité pour cette période
{{ $t('timeEntries.noEntries') }}
</div>
<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 || 'Sans titre' }}
</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 }}
@@ -56,7 +56,7 @@
<!-- Delete action -->
<button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
title="Supprimer"
:title="$t('common.delete')"
@click.stop="emit('deleteEntry', entry)"
>
<Icon name="mdi:delete-outline" size="18" />

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
@@ -99,7 +99,7 @@
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/>
<div class="min-w-0">
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
@@ -134,13 +134,16 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div>
</div>
</div>
</div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
@@ -198,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)
})
})
@@ -459,7 +459,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
dragState.value = {
entryId: entry.id,
entry,
title: entry.title || 'Sans titre',
title: entry.title || t('common.untitled'),
color: entry.project?.color ?? '#94a3b8',
durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),

View File

@@ -0,0 +1,213 @@
<template>
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
<div class="flex flex-col gap-6 p-4">
<!-- Period presets -->
<div>
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
<div class="flex flex-col gap-2">
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="currentMonth"
:label="$t('timeEntries.exportCurrentMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="lastMonth"
:label="$t('timeEntries.exportLastMonth')"
/>
<MalioRadioButton
v-model="periodMode"
name="exportPeriod"
value="custom"
:label="$t('timeEntries.exportCustomPeriod')"
/>
</div>
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
<input
v-model="customFrom"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
<input
v-model="customTo"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
</div>
</div>
<!-- User filter (admin only) -->
<div v-if="isAdmin" class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedUserIds"
:options="userOptions"
:label="$t('timeEntries.exportUsers')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Client filter -->
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedClientId"
:options="clientOptions"
:label="$t('timeEntries.exportClient')"
:empty-option-label="$t('timeEntries.exportAllClients')"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Project filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedProjectIds"
:options="filteredProjectOptions"
:label="$t('timeEntries.exportProjects')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Tag filter -->
<div class="[&>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedTagIds"
:options="tagOptions"
:label="$t('timeEntries.exportTags')"
:display-tag="true"
:display-select-all="true"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Export button -->
<button
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-600 transition"
@click="doExport"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
users: UserData[]
projects: Project[]
tags: TaskTag[]
clients: Client[]
}>()
const isOpen = defineModel<boolean>({ default: false })
const emit = defineEmits<{
(e: 'export', params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): void
}>()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
const customFrom = ref('')
const customTo = ref('')
const selectedUserIds = ref<number[]>([])
const selectedClientId = ref<number | null>(null)
const selectedProjectIds = ref<number[]>([])
const selectedTagIds = ref<number[]>([])
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id }))
)
const filteredProjectOptions = computed(() => {
let list = props.projects
if (selectedClientId.value) {
list = list.filter(p => p.client?.id === selectedClientId.value)
}
return list.map(p => ({ label: p.name, value: p.id }))
})
const tagOptions = computed(() =>
props.tags.map(t => ({ label: t.label, value: t.id }))
)
// Reset project selection when client changes
watch(selectedClientId, () => {
selectedProjectIds.value = []
})
function getDateRange(): { after: string; before: string } {
const now = new Date()
if (periodMode.value === 'currentMonth') {
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
if (periodMode.value === 'lastMonth') {
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const last = new Date(now.getFullYear(), now.getMonth(), 1)
return {
after: first.toISOString().slice(0, 10),
before: last.toISOString().slice(0, 10),
}
}
return {
after: customFrom.value,
before: customTo.value,
}
}
function doExport() {
const { after, before } = getDateRange()
if (!after || !before) return
emit('export', {
after,
before,
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
client: selectedClientId.value ?? undefined,
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
})
isOpen.value = false
}
</script>

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

@@ -0,0 +1,58 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('projects.deleteConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('projects.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
{{ $t('common.delete') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -4,19 +4,18 @@
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
Choisissez les déplacer :
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
label="Déplacer vers"
empty-option-label="Backlog (sans statut)"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full"
/>
</div>
@@ -27,7 +26,7 @@
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
Annuler
{{ $t('common.cancel') }}
</button>
<button
type="button"
@@ -35,7 +34,7 @@
:disabled="isProcessing"
@click="confirm"
>
Supprimer
{{ $t('common.delete') }}
</button>
</div>
</div>

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

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.username"
@@ -90,6 +90,8 @@ import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
item: UserData | null
@@ -114,7 +116,7 @@ const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: 'Aucun client', value: null as number | null },
{ label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
@@ -146,6 +148,13 @@ function onClientChange(value: number | null) {
}
}
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => {
if (open) {
if (props.item) {
@@ -187,10 +196,12 @@ async function handleSubmit() {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
}
if (form.password) {
payload.password = form.password
payload.plainPassword = form.password
}
if (isEditing.value && props.item) {

View File

@@ -177,13 +177,16 @@ export function useApi(): ApiClient {
) {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined)
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
if (!isFormData) {
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
}
return client<T>(url, { ...options, method, headers })

View File

@@ -5,11 +5,13 @@ export function useAvatarService() {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return api.post<{ avatarUrl: string }>(
`/users/${userId}/avatar`,
formData as unknown as Record<string, unknown>,
{
toastSuccessKey: 'profile.avatarUpdated',
}
)
}
async function remove(userId: number): Promise<void> {

View File

@@ -22,43 +22,69 @@
"clients": {
"created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès."
"deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client",
"editClient": "Modifier un client"
},
"projects": {
"title": "Projets",
"created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivés",
"hideArchived": "Masquer les projets archivés"
"hideArchived": "Masquer les projets archivés",
"noProjects": "Aucun projet trouvé.",
"noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet",
"addProjectShort": "Projet",
"editProject": "Modifier un projet",
"deleteConfirmTitle": "Supprimer le projet",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
},
"taskStatuses": {
"created": "Statut créé avec succès.",
"updated": "Statut mis à jour avec succès.",
"deleted": "Statut supprimé avec succès."
"deleted": "Statut supprimé avec succès.",
"addStatus": "Ajouter un statut",
"editStatus": "Modifier un statut",
"deleteStatus": "Supprimer le statut « {label} »",
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
"moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)"
},
"taskEfforts": {
"created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.",
"deleted": "Effort supprimé avec succès."
"deleted": "Effort supprimé avec succès.",
"addEffort": "Ajouter un effort",
"editEffort": "Modifier un effort"
},
"taskPriorities": {
"created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour avec succès.",
"deleted": "Priorité supprimée avec succès."
"deleted": "Priorité supprimée avec succès.",
"addPriority": "Ajouter une priorité",
"editPriority": "Modifier une priorité"
},
"taskTags": {
"created": "Tag créé avec succès.",
"updated": "Tag mis à jour avec succès.",
"deleted": "Tag supprimé avec succès."
"deleted": "Tag supprimé avec succès.",
"addTag": "Ajouter un tag",
"editTag": "Modifier un tag"
},
"taskGroups": {
"created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès."
"unarchived": "Groupe désarchivé avec succès.",
"addGroup": "Ajouter un groupe",
"editGroup": "Modifier un groupe"
},
"taskDocuments": {
"title": "Documents",
@@ -78,17 +104,79 @@
"archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"addTask": "Ajouter 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.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès."
"deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé"
"deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps",
"export": "Exporter",
"exportTitle": "Exporter les temps",
"exportCurrentMonth": "Mois en cours",
"exportLastMonth": "Mois dernier",
"exportCustomPeriod": "Période personnalisée",
"exportFrom": "Du",
"exportTo": "Au",
"exportUsers": "Utilisateurs",
"exportClient": "Client",
"exportProjects": "Projets",
"exportTags": "Tags",
"exportAllClients": "Tous les clients",
"exportLoading": "Export en cours...",
"exportSuccess": "Export terminé !",
"exportError": "Erreur lors de l'export."
},
"archive": {
"title": "Archives",
@@ -112,7 +200,12 @@
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog"
"backlog": "Backlog",
"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",
@@ -168,7 +261,12 @@
"cancel": "Annuler",
"save": "Enregistrer",
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
"untitled": "Sans titre",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
@@ -319,5 +417,35 @@
"noResults": "Aucun résultat",
"empty": "Aucun document lié"
}
},
"zimbra": {
"settings": {
"title": "Calendrier Zimbra",
"serverUrl": "URL du serveur CalDAV",
"serverUrlPlaceholder": "https://mail.ovh.com",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "user{'@'}domain.com",
"calendarPath": "Chemin du calendrier",
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
"password": "Mot de passe",
"passwordConfigured": "Mot de passe configuré",
"enabled": "Activer la synchronisation CalDAV",
"save": "Enregistrer",
"saved": "Configuration Zimbra enregistrée",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie",
"testFailed": "Connexion échouée"
}
},
"taskRecurrence": {
"created": "Récurrence créée",
"updated": "Récurrence mise à jour",
"deleted": "Récurrence supprimée"
},
"recurrence": {
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
}
}

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,24 +115,26 @@
<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">
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
@@ -148,6 +157,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
const auth = useAuthStore()
@@ -211,9 +221,9 @@ async function loadRefData() {
if (refData.loaded) return
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData)

View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
return navigateTo('/')
}
})

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 || '/')
@@ -23,14 +24,6 @@ export default defineNuxtConfig({
devServer: {
port: 3002,
},
nitro: {
devProxy: {
'/api': {
target: 'http://nginx',
changeOrigin: true,
},
},
},
components: [
{path: '~/components', pathPrefix: false},
],

View File

@@ -27,14 +27,15 @@
<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>
<script setup lang="ts">
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const tabs = [
@@ -44,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

@@ -471,7 +471,7 @@ const lineOptions = {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: any) => `${formatHours(ctx.raw)}`,
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
},
},
},
@@ -480,7 +480,7 @@ const lineOptions = {
beginAtZero: true,
grid: { color: '#f3f4f6' },
ticks: {
callback: (value: any) => `${value}h`,
callback: (value: number | string) => `${value}h`,
},
},
x: {

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() },
)
@@ -214,42 +228,118 @@ async function onDropBacklog(event: DragEvent) {
}
// Modal
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>
<template>
<div>
<div class="min-w-0">
<!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<div class="flex items-center gap-2">
<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'"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
@click="openTaskCreate"
>
<Icon name="mdi:view-column-outline" size="18" />
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</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'"
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:view-list-outline" size="18" />
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
</div>
</div>
@@ -309,16 +399,27 @@ 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>
<!-- Kanban View -->
<div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -326,24 +427,27 @@ onMounted(() => {
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
</div>
</div>
@@ -363,6 +467,7 @@ onMounted(() => {
v-for="task in backlogTasks"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
</div>
@@ -376,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"
@@ -446,6 +525,7 @@ onMounted(() => {
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
:projects="projects"
@saved="onSaved"
/>
</div>

View File

@@ -53,10 +53,8 @@ const ticketCountByProject = computed(() => {
const counts: Record<number, number> = {}
for (const ticket of tickets.value) {
if (ticket.status === 'new' || ticket.status === 'in_progress') {
// Extract project ID from IRI
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
if (match) {
const projectId = Number(match[1])
const projectId = extractIdFromIri(ticket.project)
if (projectId) {
counts[projectId] = (counts[projectId] ?? 0) + 1
}
}

View File

@@ -31,13 +31,13 @@
</div>
<!-- Kanban board -->
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div
v-for="col in columns"
:key="col.status"
class="min-w-0 flex-1 sm:min-w-[280px]"
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
>
<div class="mb-3 flex items-center gap-2">
<div class="mb-3 flex shrink-0 items-center gap-2">
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
@@ -45,7 +45,7 @@
</span>
</div>
<div
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
@dragover.prevent="onDragOver(col.status)"
@dragleave="onDragLeave"

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

@@ -1,5 +1,5 @@
<template>
<div>
<div class="min-w-0">
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
@@ -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,15 +68,33 @@
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 gap-4 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"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -74,30 +102,33 @@
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
Aucun ticket
</p>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
Aucun ticket
</p>
</div>
</div>
</div>
</div>
<!-- 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
@@ -116,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"
@@ -160,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' })
@@ -189,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)
@@ -211,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) {
@@ -225,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
})
@@ -267,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
@@ -309,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()
}
@@ -317,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

@@ -2,7 +2,7 @@
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
<div class="flex items-center gap-2 sm:gap-3">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
@@ -18,8 +18,8 @@
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openCreate"
>
<span class="hidden sm:inline">+ Ajouter un projet</span>
<span class="sm:hidden">+ Projet</span>
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
</button>
</div>
</div>
@@ -29,8 +29,9 @@
<div
v-for="project in projects"
:key="project.id"
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
:class="{ 'opacity-60': project.archived }"
:style="projectCardStyle(project.color)"
@click="navigateTo(`/projects/${project.id}`)"
>
<div class="flex items-center justify-between">
@@ -40,7 +41,7 @@
v-if="project.archived"
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
>
Archivé
{{ $t('common.archived') }}
</span>
</div>
<button
@@ -59,7 +60,7 @@
v-if="projects.length === 0 && !isLoading"
class="col-span-full py-12 text-center text-neutral-400"
>
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
</div>
</div>
@@ -80,6 +81,17 @@ import { useClientService } from '~/services/clients'
useHead({ title: 'Projets' })
function projectCardStyle(color: string | null) {
const hex = (color || '#222783').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)
return {
borderRadius: '16px',
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
}
}
const projectService = useProjectService()
const clientService = useClientService()

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="exportDrawerOpen = true"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
</div>
</div>
@@ -117,6 +128,15 @@
@paste="onPaste"
@delete="onDelete"
/>
<TimeTrackingExportDrawer
v-model="exportDrawerOpen"
:users="users"
:projects="projects"
:tags="tags"
:clients="clients"
@export="onExport"
/>
</div>
</template>
@@ -125,7 +145,9 @@ import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' })
@@ -144,6 +166,8 @@ const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const tags = ref<TaskTag[]>([])
const clients = ref<Client[]>([])
const exportDrawerOpen = ref(false)
const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
@@ -293,6 +317,37 @@ async function onDelete(entry: TimeEntry) {
await loadEntries()
}
async function onExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}) {
const toast = useToast()
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
toast.info({ message: t('timeEntries.exportLoading') })
try {
const result = await timeEntryService.downloadExport(params)
const url = URL.createObjectURL(result.blob)
const a = document.createElement('a')
a.href = url
a.download = result.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success({ message: t('timeEntries.exportSuccess') })
} catch {
toast.error({ message: t('timeEntries.exportError') })
}
}
async function loadEntries() {
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
@@ -307,15 +362,17 @@ async function loadEntries() {
async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
api.get<HydraCollection<Client>>('/clients'),
])
users.value = extractHydraMembers(usersData)
projects.value = extractHydraMembers(projectsData)
tags.value = extractHydraMembers(typesData)
clients.value = extractHydraMembers(clientsData)
}
onMounted(async () => {
@@ -333,6 +390,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

@@ -13,6 +13,7 @@ export type Project = {
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean
taskCount: number
}
export type ProjectWrite = {

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

@@ -12,7 +12,7 @@ export type UserData = {
export type UserWrite = {
username: string
password?: string
plainPassword?: string
roles: string[]
client?: string | null
allowedProjects?: string[]

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,45 @@ export function useTimeEntryService() {
})
}
return { getByDateRange, getActive, create, update, remove }
function getExportUrl(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.users?.length) {
params.users.forEach(id => query.append('users[]', String(id)))
}
if (params.client) query.set('client', String(params.client))
if (params.projects?.length) {
params.projects.forEach(id => query.append('projects[]', String(id)))
}
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/time_entries/export?${query.toString()}`
}
async function downloadExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): Promise<{ blob: Blob; filename: string }> {
const url = getExportUrl(params)
const response = await api.getBlob(url)
const disposition = response.headers.get('content-disposition') ?? ''
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
const filename = filenameMatch?.[1] ?? `export-temps-${params.after}_${params.before}.xlsx`
return { blob: response.data, filename }
}
return { getByDateRange, getActive, create, update, remove, getExportUrl, downloadExport }
}

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

11
frontend/utils/iri.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Extract the numeric ID from an API Platform IRI string.
* Example: "/api/projects/5" → 5
*/
export function extractIdFromIri(iri: string | null | undefined): number {
if (!iri) return 0
const lastSlash = iri.lastIndexOf('/')
if (lastSlash === -1) return 0
const id = Number(iri.substring(lastSlash + 1))
return Number.isFinite(id) ? id : 0
}

View File

@@ -0,0 +1,35 @@
<?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 Version20260316124157 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
}
}

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

@@ -80,6 +80,11 @@ fi
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
# Ensure var/log exists and is writable by PHP (www-data)
mkdir -p "${DEPLOY_DIR}/var/log"
chown www-data:www-data "${DEPLOY_DIR}/var/log"
chmod 775 "${DEPLOY_DIR}/var/log"
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
echo "Clearing cache..."
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
security: "is_granted('ROLE_USER')",
),
new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches',
@@ -24,6 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

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

@@ -51,9 +51,12 @@ class TaskDocumentDownloadController extends AbstractController
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images and PDFs, attachment for everything else
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
// SVG files are always served as attachment to prevent XSS via embedded JavaScript
$disposition = 'image/svg+xml' === $mimeType
? ResponseHeaderBag::DISPOSITION_ATTACHMENT
: (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT);
$response->setContentDisposition($disposition, $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): BinaryFileResponse
{
$afterStr = $request->query->getString('after');
$beforeStr = $request->query->getString('before');
if ('' === $afterStr || '' === $beforeStr) {
throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
}
try {
$after = new DateTimeImmutable($afterStr);
$before = new DateTimeImmutable($beforeStr);
} catch (Exception) {
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// --- Users ---
$users = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $currentUser */
$currentUser = $this->security->getUser();
$users = [$currentUser];
} else {
/** @var int[] $userIds */
$userIds = array_filter(
array_map('intval', (array) $request->query->all('users')),
fn (int $id) => $id > 0,
);
if ([] !== $userIds) {
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
}
}
// --- Client (filter projects by client) ---
$clientId = $request->query->getInt('client');
$clientProjects = null;
if ($clientId > 0) {
$clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]);
}
// --- Projects ---
$projects = null;
/** @var int[] $projectIds */
$projectIds = array_filter(
array_map('intval', (array) $request->query->all('projects')),
fn (int $id) => $id > 0,
);
if ([] !== $projectIds) {
$projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]);
}
// Merge: if both client and projects are set, intersect; if only client, use client projects
if (null !== $clientProjects && null !== $projects) {
$clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects);
$projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true)));
if ([] === $projects) {
$projects = null;
// No matching projects — force empty result by using a dummy condition
$entries = [];
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
} elseif (null !== $clientProjects) {
$projects = $clientProjects;
}
/** @var int[] $tagIds */
$tagIds = array_filter(
array_map('intval', (array) $request->query->all('tags')),
fn (int $id) => $id > 0,
);
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$users ?: null,
$projects ?: null,
$tagIds ?: null,
);
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
}

View File

@@ -91,7 +91,7 @@ class UserAvatarController extends AbstractController
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
$response->headers->set('Cache-Control', 'public, max-age=86400');
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

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private Security $security,
) {}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Project::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
// Only restrict for ROLE_CLIENT users who are NOT admins
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$allowedProjectIds = $user->getAllowedProjects()->map(
fn (Project $project) => $project->getId(),
)->toArray();
if ([] === $allowedProjectIds) {
$queryBuilder->andWhere('1 = 0');
return;
}
$queryBuilder
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
->setParameter('allowed_project_ids', $allowedProjectIds)
;
}
}

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