Compare commits

...

173 Commits

Author SHA1 Message Date
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
gitea-actions
fb0e6c1ea4 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-16 08:52:02 +00:00
Matthieu
6d3ecc1322 Merge branch 'feature/client-portal' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:51:48 +01:00
Matthieu
f5986090c0 feat(deploy) : add deploy script and nginx config for bare Ubuntu server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:51:29 +01:00
Matthieu
d6399c20e1 fix : fix MCP create-task tool crashing on task creation
CreateTaskTool called nonexistent findMaxNumberByProject instead of
findMaxNumberByProjectForUpdate. Also removed FOR UPDATE clause from the
query as PostgreSQL does not support it with aggregate functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:36 +01:00
Matthieu
a972d243f5 style : center and resize view toggle buttons on my-tasks page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:34 +01:00
Matthieu
56bf88f293 fix : prevent document delete button from submitting the TaskModal form
The delete button in TaskDocumentList lacked type="button", causing it to
act as a submit button inside the form, which triggered handleSubmit and
closed the modal before the confirmation dialog could appear. Also added
guards to prevent closing TaskModal while a sub-modal is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:32 +01:00
9d80e017c2 docs : complete architecture tree in README with all directories
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:27:52 +01:00
4e91507158 docs : rewrite README with full project documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:25:42 +01:00
318f14ea88 docs : update CLAUDE.md with avatar feature context and gotchas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:21:12 +01:00
202b516dc3 fix(avatar) : install symfony/mime for server-side MIME type detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:13:18 +01:00
98782a9849 fix(avatar) : add explicit import for useAvatarService in profile page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:12:38 +01:00
b978adf9ae fix(avatar) : move avatar service to composables for Nuxt auto-import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:11:03 +01:00
e4fc34b90f refactor : simplify codebase and fix critical issues
Backend:
- Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped)
- Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction)
- Add unique constraint on task (project_id, number) with migration
- Fix MIME type validation: use server-detected finfo instead of client-supplied type
- Add allowlist of permitted MIME types for uploads
- Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1
- Fix notification sent even when ticket status unchanged
- Remove redundant exception constructors
- Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi)
- Consolidate duplicate checks in processors

Frontend:
- Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect)
- Fix client-tickets toast key copy-paste bug
- Merge duplicated tasks service methods (getByProject + getByProjectArchived)
- Extract shared uploadWithRelation helper in task-documents service
- Extract formatFileSize utility from duplicated component code
- Extract status transition logic into useClientTicketHelpers composable
- Remove dead code (unused router, handleLogout, empty script blocks)
- Merge duplicate watchers and onMounted calls
- Normalize arrow functions to function declarations per convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:09:16 +01:00
a5144443a4 fix(avatar) : address review findings — security and UX fixes
- Use getMimeType() instead of getClientMimeType() to prevent MIME spoofing
- Change IsGranted to IS_AUTHENTICATED_FULLY so ROLE_CLIENT can access avatars
- Remove Groups from avatarFileName (only avatarUrl needed by frontend)
- Disable aggressive caching to prevent stale avatar images
- Add error handling to avatar upload in profile page
- Use i18n for "Mon profil" button text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:02:27 +01:00
afd4baed92 feat(avatar) : replace initials with UserAvatar component everywhere
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:58:46 +01:00
e8f0202b15 feat(avatar) : add profile page with avatar upload and crop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:57:55 +01:00
962b3d935c feat(avatar) : add AvatarCropper modal with vue-advanced-cropper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:56:11 +01:00
cea22f977b feat(avatar) : add UserAvatar component with image/initials fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:52 +01:00
5613a7c92b feat(avatar) : add avatar service, DTO update, and cropper dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:39 +01:00
4d0aa65920 feat(avatar) : add avatar upload/serve/delete controller
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:54:23 +01:00
63315c0a15 feat(avatar) : add avatarFileName field to User entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:53:43 +01:00
cff16611f4 docs : add user avatar implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:50:07 +01:00
96f5c7c91c docs : add user avatar feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:47:38 +01:00
f7a76c9e9b feat(frontend) : add date filter component to time-tracking page
Reusable DateFilter component using @vuepic/vue-datepicker with day/week
toggle. Selecting a day switches to day view, selecting a week navigates
the calendar to that week. Includes "Aujourd'hui" and "Cette semaine"
quick shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:46:48 +01:00
7047f64a6b fix(portal) : handle submittedBy as object or IRI in canEdit check
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:40:54 +01:00
cd8cea45c1 fix(security) : allow ROLE_CLIENT to read projects
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:39:41 +01:00
1f31a3a33f fix(portal) : embed project id/name in /me response for client users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:37:18 +01:00
254f8bc411 fix(admin) : handle null/IRI client in project filter for UserDrawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:34:21 +01:00
239cd6398e docs : update CLAUDE.md with client portal context and gotchas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:26:05 +01:00
318b6198da feat(portal) : add drag & drop status change on client ticket kanban
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:16:22 +01:00
4e3e854aa2 fix(portal) : allow admin to edit tickets and enable document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:12:55 +01:00
49cd971e3e feat(project) : add client tickets panel to project page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:28 +01:00
ffe4a0117c feat(portal) : allow client to edit own tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:25 +01:00
d2f6d84d03 feat(portal) : replace ticket list with kanban board
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:23 +01:00
2a874046d3 feat : allow client to edit own tickets and protect status fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:35:11 +01:00
f09ef67117 feat : date filter, project drawer, and misc frontend improvements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:26 +01:00
046ee396d3 feat(fixtures) : add users alice/bob/charlie and distribute task assignees
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:14 +01:00
0ba487cfa9 feat(fixtures) : add client users, client tickets, and ticket-task link
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:20:27 +01:00
a2fc8e6e52 feat(task) : add client ticket selector in TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:14:56 +01:00
6c910e7fcc fix : use native SQL for JSON roles query in PostgreSQL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:11:54 +01:00
6d7e6f5f48 fix : allow admin users to create client tickets on any project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:07:19 +01:00
0c8fb654a9 fix(portal) : allow admin+client users to access both views and add admin link
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:06:09 +01:00
f8748c4061 fix(portal) : handle ticket creation error and hide new ticket button for admins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:04:20 +01:00
2c28a4ad1d fix(notification) : add route priority to prevent API Platform conflict
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:59:47 +01:00
cf1cf1ff5c chore : add MCP bundle config files and .mcp.json for Claude Code
Auto-generated by Symfony Flex recipe + .mcp.json for local STDIO transport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:16 +01:00
0724d38a26 feat(frontend) : add portal pages, update auth middleware and DTOs for client portal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:51:51 +01:00
17c5160f2c docs : add MCP server documentation to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:49:57 +01:00
40d6f7693f feat(i18n) : add notification translations in French 2026-03-15 19:48:27 +01:00
e63ed63dd8 feat(frontend) : integrate NotificationBell in AppTopNav navbar 2026-03-15 19:48:13 +01:00
ad8142ac9d feat(frontend) : add NotificationBell component with dropdown 2026-03-15 19:48:03 +01:00
f7afe1c6fb docs : add MCP server section to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:47:43 +01:00
697075eea2 feat(frontend) : add useNotifications composable with polling 2026-03-15 19:47:33 +01:00
587733e6f9 feat(frontend) : add notification DTO and service 2026-03-15 19:47:21 +01:00
59b11f1225 feat(notification) : hook NotificationService into ticket processors 2026-03-15 19:47:06 +01:00
4094048aba feat(notification) : add NotificationService and UserRepository::findByRole 2026-03-15 19:46:37 +01:00
ce2eaa03e1 feat(notification) : add unread-count and mark-all-read custom controllers 2026-03-15 19:46:10 +01:00
d932359024 feat(notification) : add NotificationProvider filtered by current user 2026-03-15 19:45:58 +01:00
669c36cea1 feat(notification) : add Notification entity, repository, and migration 2026-03-15 19:45:47 +01:00
3d1a510d82 feat : add 22 MCP tool classes for projects, tasks, and time tracking
Tools: list-users, list-clients, list/get/create/update-project,
list/get/create/update/delete-task, list-statuses/priorities/efforts/tags,
list/create/update-group, list/create/update/delete-time-entry.

Attribute moved to class level for SDK discovery compatibility.
Install nyholm/psr7 for HTTP transport PSR-17 support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:45:39 +01:00
68dd9599a9 feat(auth) : redirect client users to /portal after login and extract ticket helpers composable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:53 +01:00
0d21e59023 feat(admin) : add client tickets tab with list, filters, status change, and delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:49 +01:00
7210a0d96f feat(kanban) : show client ticket icon on task cards, my-tasks, and task modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:46 +01:00
7099f1ca95 feat(documents) : generalize TaskDocumentUpload and add upload zone to ticket detail modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:42:43 +01:00
e16fd2053e feat : MCP server infrastructure setup
Install symfony/mcp-bundle, add STDIO + HTTP transport config,
API token auth on User entity with custom authenticator and firewall,
generate-api-token console command, Nginx /_mcp location, fixture token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:33:52 +01:00
760f5b6ad6 feat(frontend) : add i18n translations for client portal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:31:04 +01:00
adf050505d feat(frontend) : add client ticket support to task-documents service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:52 +01:00
12d043a50f feat(frontend) : add clientTicket to Task DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:38 +01:00
bfd418851e feat(frontend) : add client-tickets service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:32 +01:00
4fbbead3e3 feat(frontend) : add ClientTicket DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:19 +01:00
64961631e4 feat(frontend) : add client user management to admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:30:10 +01:00
7f2371e522 feat(frontend) : update UserData DTO for client users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:29:36 +01:00
851953df1e feat : generalize TaskDocumentProcessor for client tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:28:04 +01:00
b6cfe9d7d4 feat : add ClientTicketProvider with filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:24 +01:00
f33f2f95ec feat : add ClientTicketStatusProcessor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:10 +01:00
9a9416d6c8 fix : apply review fixes to MCP plan and spec
Fix getIsFinal() method name, enrich create/update tool return formats
to match get/list consistency, fix duplicate Reference section in spec,
correct tool count to 22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:27:06 +01:00
f27297517c feat : add ClientTicketNumberProcessor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:27:00 +01:00
d2e27a04ce feat : add ClientTicketRepository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:45 +01:00
10cde5e2f9 feat : add client portal migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:36 +01:00
926d6d54c5 feat : generalize TaskDocument for client tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:25:02 +01:00
a538bb3601 feat : add clientTicket relation to Task entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:50 +01:00
97dcff8542 feat : add ClientTicket entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:36 +01:00
87ab281099 feat : extend User entity with client and allowedProjects
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:23:10 +01:00
2b9095b1a2 docs : add MCP server implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:22:42 +01:00
05e24db6ca feat(security) : add role hierarchy for client portal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:21:28 +01:00
63febbea45 fix(security) : add ROLE_USER security on all read endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:21:19 +01:00
edc441f363 fix(security) : exclude ROLE_USER for ROLE_CLIENT users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:20:46 +01:00
f4eec2e6e9 docs : add client portal implementation plans (phases 1-3)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:18:25 +01:00
5547c67b30 docs : add create-group and update-group tools to MCP spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:17:44 +01:00
9e19adc09a docs : add HTTP transport + API token auth to MCP spec
Both STDIO (local) and HTTP (LAN) transports are now in scope.
HTTP secured by API token on User entity with custom authenticator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:15:20 +01:00
8d24949186 docs : update MCP server spec with review fixes
Adds list-users, list-clients, update-project tools. Fixes time entry
title as optional, adds startedAt to update-time-entry, adds taskId
filter, pagination limits, eager joins, security model docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:12:32 +01:00
c2fa308f1e docs : add MCP server design spec for Lesstime
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:08:27 +01:00
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
223 changed files with 24263 additions and 1646 deletions

6
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET="a64f5614357bf56aecb1d7470e431535" APP_SECRET="change_me_in_env_local"
APP_DEBUG=1 APP_DEBUG=1
DEFAULT_URI=http://localhost/ DEFAULT_URI=http://localhost/
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.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_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400 JWT_TOKEN_TTL=86400
JWT_COOKIE_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" 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 set -euo pipefail
mkdir -p release mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \ tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \ bin \
config \ config \
migrations \ migrations \
public \ public \
src \ src \
templates \
vendor \ vendor \
composer.json \ composer.json \
composer.lock \ composer.lock \

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem /config/jwt/*.pem
###< lexik/jwt-authentication-bundle ### ###< 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>

22
.mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"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,9 +12,15 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure ## Structure
``` ```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration) 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) src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor) 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)
src/Command/ # Commandes console (GenerateApiTokenCommand)
src/Repository/ # Repositories Doctrine src/Repository/ # Repositories Doctrine
src/DataFixtures/ # Fixtures src/DataFixtures/ # Fixtures
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
@@ -23,12 +29,12 @@ migrations/ # Migrations Doctrine
docs/plans/ # Plans d'implémentation docs/plans/ # Plans d'implémentation
docs/superpowers/ # Plans et specs superpowers docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4 frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin) 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 (pas "layout") frontend/layouts/ # Layouts (default, portal)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/) 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) frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer) 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) 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/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/) frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
``` ```
@@ -63,6 +69,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` 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 ### Backend
- Toujours `declare(strict_types=1)` en haut des fichiers PHP - Toujours `declare(strict_types=1)` en haut des fichiers PHP
@@ -70,6 +83,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`) - Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check` - Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
- PHP CS Fixer : règles Symfony + PSR-12 + strict types - PHP CS Fixer : règles Symfony + PSR-12 + strict types
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
### Frontend ### Frontend
@@ -79,9 +99,23 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Middleware global `auth.global.ts` protège les routes - Middleware global `auth.global.ts` protège les routes
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
- 4 espaces d'indentation - 4 espaces d'indentation
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
### MCP Server
- 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`
- Générer un token : `php bin/console app:generate-api-token <username>`
- Config : `config/packages/mcp.yaml`, firewall dans `config/packages/security.yaml`
- Attribut `#[McpTool]` doit être sur la **classe** (pas la méthode `__invoke`) pour la discovery SDK
### Nginx ### Nginx
- `/_mcp` → Symfony (MCP HTTP transport)
- `/api/*` → Symfony (via try_files + index.php) - `/api/*` → Symfony (via try_files + index.php)
- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check` - `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check`
- `/` → SPA frontend (`frontend/dist/`) - `/` → SPA frontend (`frontend/dist/`)
@@ -97,3 +131,8 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
## Fixtures ## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN) - User admin : `admin` / `admin` (ROLE_ADMIN)
- 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)

228
README.md
View File

@@ -1 +1,229 @@
# Lesstime # Lesstime
Application de gestion de projet avec suivi du temps et portail client.
## Stack
| Couche | Technologies |
|--------|-------------|
| **Backend** | PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM |
| **Frontend** | Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS |
| **Base de données** | PostgreSQL 16 |
| **Auth** | JWT HTTP-only cookie (lexik/jwt-authentication-bundle) |
| **Infrastructure** | Docker (PHP-FPM, Nginx, PostgreSQL) |
## Fonctionnalités
- Gestion de projets et tâches (kanban, groupes, priorités, tags, efforts)
- Suivi du temps (timer, calendrier, vue liste)
- Portail client avec tickets (bug, amélioration, autre)
- Gestion de documents (upload, prévisualisation, téléchargement)
- Profil utilisateur avec avatar (crop circulaire)
- Notifications temps réel
- Intégration Gitea (issues, repos)
- Serveur MCP pour assistants IA
- Multi-langue (i18n)
## Prérequis
- Docker & Docker Compose
- Git
## Installation
```bash
# 1. Cloner le repo
git clone <url> && cd lesstime
# 2. Démarrer les containers
make start
# 3. Installation complète (composer, migrations, fixtures, build Nuxt)
make install
```
L'application est accessible sur **http://localhost:8082**.
### Comptes de test (fixtures)
| Utilisateur | Mot de passe | Rôle | Détails |
|-------------|-------------|------|---------|
| `admin` | `admin` | ROLE_ADMIN | Administrateur |
| `alice` | `alice` | ROLE_USER | Utilisateur interne |
| `bob` | `bob` | ROLE_USER | Utilisateur interne |
| `charlie` | `charlie` | ROLE_USER | Utilisateur interne |
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
## Commandes
### Docker
```bash
make start # Démarrer les containers
make stop # Arrêter les containers
make restart # Redémarrer les containers
make shell # Shell dans le container PHP
make shell-root # Shell root dans le container PHP
```
### Développement
```bash
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
make cache-clear # Vider le cache Symfony
make logs-dev # Tail logs Symfony
```
### Base de données
```bash
make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures
make db-reset # Reset BDD + migrations + fixtures (⚠️ supprime les données)
```
### Tests & Qualité
```bash
make test # PHPUnit
make php-cs-fixer-allow-risky # Fix code style PHP (Symfony + PSR-12)
```
### Installation complète
```bash
make install # Composer + migrations + fixtures + build Nuxt
make reset # Tout supprimer et réinstaller (⚠️ supprime la BDD)
```
## Architecture
```
src/
├── Entity/ # Entités Doctrine
├── ApiResource/ # Ressources API Platform (découplées)
├── State/ # Providers et Processors API Platform
├── Controller/ # Controllers custom Symfony
├── Service/ # Services métier
├── EventListener/ # Listeners Doctrine
├── Exception/ # Exceptions custom
├── Security/ # Authenticators custom
├── Repository/ # Repositories Doctrine
├── Command/ # Commandes console
├── DataFixtures/ # Fixtures
└── Mcp/Tool/ # MCP tools par domaine
├── Project/
├── Task/
├── TaskMeta/
├── TimeEntry/
└── Reference/
frontend/
├── pages/ # Pages Nuxt (routing auto)
│ ├── portal/ # Pages portail client
│ └── projects/ # Pages projets
├── layouts/ # Layouts (default, portal)
├── components/ # Composants Vue
│ ├── ui/ # Composants génériques
│ ├── task/ # Tâches
│ ├── user/ # Utilisateur (avatar, etc.)
│ ├── project/ # Projets
│ ├── client/ # Clients
│ ├── client-ticket/ # Tickets client
│ ├── admin/ # Administration
│ ├── notification/ # Notifications
│ └── time-tracking/ # Suivi du temps
├── composables/ # Composables (useApi, useNotifications, etc.)
├── stores/ # Stores Pinia (auth, ui, timer)
├── services/ # Services API
│ └── dto/ # Types TypeScript
├── plugins/ # Plugins Nuxt
├── utils/ # Utilitaires
├── i18n/locales/ # Traductions
└── middleware/ # Middleware auth
config/ # Config Symfony
migrations/ # Migrations Doctrine
docker/ # Dockerfiles et config Nginx
```
## Docker
| Container | Port | Description |
|-----------|------|-------------|
| `php-lesstime-fpm` | 3002 (dev Nuxt) | PHP-FPM + Node 24 |
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données |
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
## API
Toutes les routes API sont préfixées `/api` (API Platform).
- Documentation auto-générée : **http://localhost:8082/api**
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
## Serveur MCP
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA d'interagir avec les données.
### Tools disponibles (22)
| Domaine | Tools |
|---------|-------|
| Reference | `list-users`, `list-clients` |
| Project | `list-projects`, `get-project`, `create-project`, `update-project` |
| Task | `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task` |
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
### Configuration locale (STDIO)
```json
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
}
}
}
```
### Configuration réseau (HTTP)
```json
{
"mcpServers": {
"lesstime": {
"type": "url",
"url": "http://<ip-serveur>:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
}
}
}
```
### Gestion des tokens API
```bash
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
```
## Déploiement
1. Déployer le code sur le serveur
2. `composer install --no-dev --optimize-autoloader`
3. `php bin/console doctrine:migrations:migrate --no-interaction`
4. `php bin/console cache:clear --env=prod`
5. `cd frontend && npm install && npm run build:dist`
6. `docker restart nginx-lesstime`
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
## Licence
Propriétaire — Tous droits réservés.

View File

@@ -14,8 +14,10 @@
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0", "nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
"symfony/console": "8.0.*", "symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*", "symfony/dotenv": "8.0.*",
@@ -23,12 +25,15 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*", "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-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*" "symfony/yaml": "8.0.*"
}, },
@@ -87,8 +92,6 @@
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94", "friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0", "phpunit/phpunit": "^13.0"
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
} }
} }

2226
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,13 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle; use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [ return [
FrameworkBundle::class => ['all' => true], FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true], SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true], DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true], DoctrineMigrationsBundle::class => ['all' => true],
@@ -22,4 +22,6 @@ return [
ApiPlatformBundle::class => ['all' => true], ApiPlatformBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true], LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
]; ];

View File

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

View File

@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory

23
config/packages/mcp.yaml Normal file
View File

@@ -0,0 +1,23 @@
mcp:
app: 'lesstime'
version: '1.0.0'
description: 'Lesstime project management — projects, tasks, time tracking'
instructions: |
This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
Groups are PER-PROJECT (each group belongs to one project).
Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks.
Use list-users and list-clients to discover valid user and client IDs.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
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

@@ -1,4 +1,7 @@
security: security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -19,12 +22,21 @@ security:
pattern: ^/login_check pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
login_throttling:
max_attempts: 5
interval: '1 minute'
json_login: json_login:
check_path: /login_check check_path: /login_check
username_path: username username_path: username
password_path: password password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure failure_handler: lexik_jwt_authentication.handler.authentication_failure
mcp:
pattern: ^/_mcp
stateless: true
provider: app_user_provider
custom_authenticators:
- App\Security\ApiTokenAuthenticator
api: api:
pattern: ^/api pattern: ^/api
stateless: true stateless: true
@@ -50,6 +62,8 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public # Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { 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 } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test: when@test:

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 * rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: [] * 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" * 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" * 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 * 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{ * @psalm-type SecurityConfig = array{
* access_denied_url?: scalar|Param|null, // Default: null * access_denied_url?: scalar|Param|null, // Default: null
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" * 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 * 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_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false * enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true * enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true * enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true * enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
* enable_docs?: bool|Param, // Enable the docs // 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 * enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
@@ -1610,56 +1578,234 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app" * cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
* }, * },
* } * }
* @psalm-type McpConfig = array{
* app?: scalar|Param|null, // Default: "app"
* version?: scalar|Param|null, // Default: "0.0.1"
* description?: scalar|Param|null, // Default: null
* icons?: list<array{ // Default: []
* src?: scalar|Param|null,
* mime_type?: scalar|Param|null, // Default: null
* sizes?: list<scalar|Param|null>,
* }>,
* website_url?: scalar|Param|null, // Default: null
* pagination_limit?: int|Param, // Default: 50
* instructions?: scalar|Param|null, // Default: null
* client_transports?: array{
* stdio?: bool|Param, // Default: false
* http?: bool|Param, // Default: false
* },
* discovery?: array{
* scan_dirs?: list<scalar|Param|null>,
* exclude_dirs?: list<scalar|Param|null>,
* },
* http?: array{
* path?: scalar|Param|null, // Default: "/_mcp"
* session?: array{
* store?: "file"|"memory"|"cache"|Param, // Default: "file"
* directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions"
* cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions"
* prefix?: scalar|Param|null, // Default: "mcp-"
* ttl?: int|Param, // Default: 3600
* },
* },
* }
* @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{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig, * nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig, * nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig, * nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig, * nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,

3
config/routes/mcp.yaml Normal file
View File

@@ -0,0 +1,3 @@
mcp:
resource: .
type: mcp

View File

@@ -8,6 +8,7 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
imports: imports:
- { resource: version.yaml } - { resource: version.yaml }
@@ -39,3 +40,7 @@ services:
App\Controller\TaskDocumentDownloadController: App\Controller\TaskDocumentDownloadController:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'

View File

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

View File

@@ -0,0 +1,50 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/frontend/.output/public;
index index.html;
client_max_body_size 55m;
location ^~ /api/ {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/lesstime/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ^~ /_mcp {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
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;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

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

View File

@@ -7,6 +7,11 @@ server {
client_max_body_size 55m; client_max_body_size 55m;
location ^~ /_mcp {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /api/ { location ^~ /api/ {
root /var/www/html/public; root /var/www/html/public;
try_files $uri /index.php?$query_string; try_files $uri /index.php?$query_string;

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,970 @@
# Client Portal Phase 3 — Notifications
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service).
**Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`.
> **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity.
**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript
**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md`
**Depends on:** Phase 1 + Phase 2
---
## Chunk 1: Notification Entity & Migration
### Task 1: Create the Notification entity
- [ ] **Create `src/Entity/Notification.php`** with the following content:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
provider: NotificationProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Patch(
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
),
],
normalizationContext: ['groups' => ['notification:read']],
denormalizationContext: ['groups' => ['notification:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['notification:read'])]
private ?User $user = null;
#[ORM\Column(length: 50)]
#[Groups(['notification:read'])]
private ?string $type = null;
#[ORM\Column(length: 255)]
#[Groups(['notification:read'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Groups(['notification:read'])]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['notification:read'])]
private ?ClientTicket $relatedTicket = null;
#[ORM\Column]
#[Groups(['notification:read', 'notification:write'])]
private bool $isRead = false;
#[ORM\Column]
#[Groups(['notification:read'])]
private ?DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getRelatedTicket(): ?ClientTicket
{
return $this->relatedTicket;
}
public function setRelatedTicket(?ClientTicket $relatedTicket): static
{
$this->relatedTicket = $relatedTicket;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): static
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}
```
### Task 2: Create the NotificationRepository
- [ ] **Create `src/Repository/NotificationRepository.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
public function countUnreadByUser(User $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
}
public function markAllReadByUser(User $user): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', 'true')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->executeStatement();
}
}
```
### Task 3: Generate and run the migration
- [ ] **Run inside the PHP container** (`make shell`):
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`.
- [ ] **Commit:**
```bash
git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/
git commit -m "feat(notification) : add Notification entity, repository, and migration"
```
---
## Chunk 2: NotificationProvider & Custom Endpoints
### Task 4: Create the NotificationProvider
- [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user:
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<Notification>
*/
final readonly class NotificationProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object
{
$user = $this->security->getUser();
return $this->notificationRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
30,
);
}
}
```
- [ ] **Commit:**
```bash
git add src/State/NotificationProvider.php
git commit -m "feat(notification) : add NotificationProvider filtered by current user"
```
### Task 5: Create the UnreadCountController
- [ ] **Create `src/Controller/NotificationUnreadCountController.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $count]);
}
}
```
### Task 6: Create the MarkAllReadController
- [ ] **Create `src/Controller/MarkAllReadController.php`**:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
return new Response(null, Response::HTTP_NO_CONTENT);
}
}
```
- [ ] **Commit:**
```bash
git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php
git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers"
```
---
## Chunk 3: NotificationService & Processor Integration
### Task 7: Create NotificationService
- [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ClientTicket;
use App\Entity\Notification;
use App\Entity\User;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
) {}
/**
* Notify all ROLE_ADMIN users that a new ticket was created.
*/
public function createForTicketCreated(ClientTicket $ticket): void
{
$admins = $this->userRepository->findByRole('ROLE_ADMIN');
$number = sprintf('CT-%03d', $ticket->getNumber());
$projectName = $ticket->getProject()?->getName() ?? '';
foreach ($admins as $admin) {
$notification = new Notification();
$notification->setUser($admin);
$notification->setType('ticket_created');
$notification->setTitle('Nouveau ticket client ' . $number);
$notification->setMessage($ticket->getTitle() . ' — ' . $projectName);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
/**
* Notify the ticket submitter that the status has changed.
*/
public function createForStatusChange(ClientTicket $ticket): void
{
$submittedBy = $ticket->getSubmittedBy();
if (null === $submittedBy) {
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusLabel = $ticket->getStatus();
$message = 'Nouveau statut : ' . $statusLabel;
if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) {
$message .= ' — ' . $ticket->getStatusComment();
}
$notification = new Notification();
$notification->setUser($submittedBy);
$notification->setType('ticket_status_changed');
$notification->setTitle('Ticket ' . $number . ' mis à jour');
$notification->setMessage($message);
$notification->setRelatedTicket($ticket);
$notification->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($notification);
$this->entityManager->flush();
}
}
```
### Task 8: Add findByRole method to UserRepository
- [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`:
```php
/**
* @return User[]
*/
public function findByRole(string $role): array
{
return $this->createQueryBuilder('u')
->where('u.roles LIKE :role')
->setParameter('role', '%"' . $role . '"%')
->getQuery()
->getResult();
}
```
- [ ] **Commit:**
```bash
git add src/Service/NotificationService.php src/Repository/UserRepository.php
git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole"
```
### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST)
- [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted:
Add to constructor parameters:
```php
private readonly NotificationService $notificationService,
```
Add import at the top:
```php
use App\Service\NotificationService;
```
After `$this->entityManager->flush();` in the POST handling block, add:
```php
$this->notificationService->createForTicketCreated($data);
```
### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH)
- [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted:
Add to constructor parameters:
```php
private readonly NotificationService $notificationService,
```
Add import at the top:
```php
use App\Service\NotificationService;
```
After `$this->entityManager->flush();` in the PATCH handling block, add:
```php
$this->notificationService->createForStatusChange($data);
```
- [ ] **Commit:**
```bash
git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php
git commit -m "feat(notification) : hook NotificationService into ticket processors"
```
---
## Chunk 4: Frontend — DTO & Service
### Task 11: Create the Notification DTO
- [ ] **Create `frontend/services/dto/notification.ts`**:
```typescript
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
export type Notification = {
'@id'?: string
id: number
user: string
type: NotificationType
title: string
message: string
relatedTicket: string | null
isRead: boolean
createdAt: string
}
```
### Task 12: Create the notifications service
- [ ] **Create `frontend/services/notifications.ts`**:
```typescript
import type { Notification } from './dto/notification'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useNotificationService() {
const api = useApi()
async function getAll(): Promise<Notification[]> {
const data = await api.get<HydraCollection<Notification>>('/notifications')
return extractHydraMembers(data)
}
async function markAsRead(id: number): Promise<void> {
await api.patch(`/notifications/${id}`, { isRead: true }, {
toast: false,
})
}
async function markAllAsRead(): Promise<void> {
await api.post('/notifications/mark-all-read', {}, {
toast: false,
})
}
async function getUnreadCount(): Promise<number> {
const data = await api.get<{ count: number }>('/notifications/unread-count', {}, {
toast: false,
})
return data.count
}
return { getAll, markAsRead, markAllAsRead, getUnreadCount }
}
```
- [ ] **Commit:**
```bash
git add frontend/services/dto/notification.ts frontend/services/notifications.ts
git commit -m "feat(frontend) : add notification DTO and service"
```
---
## Chunk 5: Frontend — Composable & Component
### Task 13: Create the useNotifications composable
- [ ] **Create `frontend/composables/useNotifications.ts`**:
```typescript
import type { Notification } from '~/services/dto/notification'
import { useNotificationService } from '~/services/notifications'
const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes
export function useNotifications() {
const unreadCount = useState<number>('notification-unread-count', () => 0)
const notifications = useState<Notification[]>('notification-list', () => [])
const isLoading = useState<boolean>('notification-loading', () => false)
const service = useNotificationService()
let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchUnreadCount(): Promise<void> {
try {
unreadCount.value = await service.getUnreadCount()
} catch {
// Silently ignore polling errors
}
}
async function fetchNotifications(): Promise<void> {
isLoading.value = true
try {
notifications.value = await service.getAll()
} finally {
isLoading.value = false
}
}
async function markAsRead(id: number): Promise<void> {
await service.markAsRead(id)
const notif = notifications.value.find(n => n.id === id)
if (notif && !notif.isRead) {
notif.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
async function markAllAsRead(): Promise<void> {
await service.markAllAsRead()
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
}
function startPolling(): void {
fetchUnreadCount()
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
unreadCount,
notifications,
isLoading,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
}
}
```
- [ ] **Commit:**
```bash
git add frontend/composables/useNotifications.ts
git commit -m "feat(frontend) : add useNotifications composable with polling"
```
### Task 14: Create the NotificationBell component
- [ ] **Create `frontend/components/notification/NotificationBell.vue`**:
```vue
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<Transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<h3 class="text-sm font-semibold text-neutral-800">
{{ $t('notification.title') }}
</h3>
<button
v-if="unreadCount > 0"
type="button"
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
@click="handleMarkAllRead"
>
{{ $t('notification.markAllRead') }}
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
</div>
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
{{ $t('notification.empty') }}
</div>
<template v-else>
<button
v-for="notif in notifications"
:key="notif.id"
type="button"
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
:class="{ 'bg-primary-50': !notif.isRead }"
@click="handleClick(notif)"
>
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-neutral-800 truncate">
{{ notif.title }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ notif.message }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ formatRelativeDate(notif.createdAt) }}
</p>
</div>
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Notification } from '~/services/dto/notification'
import { useNotifications } from '~/composables/useNotifications'
const {
unreadCount,
notifications,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
} = useNotifications()
const bellRef = ref<HTMLElement>()
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
fetchNotifications()
}
}
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
if (notif.relatedTicket) {
const ticketId = notif.relatedTicket.split('/').pop()
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
}
async function handleMarkAllRead() {
await markAllAsRead()
}
const { t } = useI18n()
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMin / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMin < 1) return t('notification.timeAgo.now')
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
// Close dropdown when clicking outside
function onClickOutside(event: MouseEvent) {
if (!bellRef.value?.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
startPolling()
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
```
- [ ] **Commit:**
```bash
git add frontend/components/notification/NotificationBell.vue
git commit -m "feat(frontend) : add NotificationBell component with dropdown"
```
---
## Chunk 6: Layout Integration & i18n
### Task 15: Integrate NotificationBell in AppTopNav
- [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `<div class="ml-auto flex gap-4 ...">` block (line 10):
Replace:
```vue
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
<div class="group relative flex gap-2 sm:gap-4">
```
With:
```vue
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
```
No imports needed — Nuxt auto-imports components from `frontend/components/`.
- [ ] **Commit:**
```bash
git add frontend/components/ui/AppTopNav.vue
git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar"
```
### Task 16: Add i18n translations
- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys):
```json
"notification": {
"title": "Notifications",
"markAllRead": "Tout marquer comme lu",
"empty": "Aucune notification",
"ticketCreated": "Nouveau ticket client {number}",
"ticketStatusChanged": "Ticket {number} mis à jour",
"timeAgo": {
"now": "À l'instant",
"minutes": "Il y a {n} min",
"hours": "Il y a {n}h",
"days": "Il y a {n}j"
}
}
```
- [ ] **Commit:**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(i18n) : add notification translations in French"
```
---
## Chunk 7: Verification & Cleanup
### Task 17: Test backend endpoints manually
- [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`):
1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}`
2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination)
3. `GET /api/notifications/unread-count` — should return `{"count": 0}`
4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin
5. `GET /api/notifications` — should now list the `ticket_created` notification
6. `GET /api/notifications/unread-count` — should return `{"count": 1}`
7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read
8. `POST /api/notifications/mark-all-read` — should return 204
### Task 18: Test frontend notification bell
- [ ] **Start dev server** (`make dev-nuxt`) and verify:
1. The bell icon appears in the top navigation bar, to the left of the user avatar
2. Badge shows unread count (or is hidden when 0)
3. Clicking the bell opens a dropdown with notification list
4. Clicking a notification marks it as read and navigates appropriately
5. "Tout marquer comme lu" button works
6. Polling updates the badge every 2 minutes
- [ ] **Final commit (if any fixes needed):**
```bash
git add -A
git commit -m "fix(notification) : polish notification bell and fix edge cases"
```

View File

@@ -0,0 +1,385 @@
# Date Filter Component Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`, enabling filtering by single day or date range.
**Architecture:** A wrapper component `DateFilter.vue` encapsulates `VueDatePicker` with project-consistent styling. It integrates into the existing filter bar on the time-tracking page. Filtering is client-side, matching the existing project/tag filter pattern.
**Tech Stack:** Vue 3, @vuepic/vue-datepicker, Tailwind CSS, @nuxtjs/i18n
---
## Chunk 1: Setup and Component
### Task 1: Install @vuepic/vue-datepicker and configure Nuxt
**Files:**
- Modify: `frontend/package.json`
- Modify: `frontend/nuxt.config.ts:1-66`
- [ ] **Step 1: Install the package**
Run inside the PHP container (where Node is available):
```bash
cd /home/r-dev/Lesstime/frontend && npm install @vuepic/vue-datepicker
```
- [ ] **Step 2: Add transpile config to nuxt.config.ts**
In `frontend/nuxt.config.ts`, add `build.transpile` after the `typescript` block:
```typescript
export default defineNuxtConfig({
// ... existing config ...
typescript: {
strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
})
```
- [ ] **Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/nuxt.config.ts
git commit -m "feat(frontend) : add @vuepic/vue-datepicker dependency"
```
---
### Task 2: Add i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json:167-170`
- [ ] **Step 1: Add date filter translations to fr.json**
In `frontend/i18n/locales/fr.json`, add keys inside the existing `"common"` block:
```json
"common": {
"cancel": "Annuler",
"loading": "Chargement...",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"clear": "Effacer"
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add date filter i18n translations"
```
---
### Task 3: Create DateFilter.vue component
**Files:**
- Create: `frontend/components/ui/DateFilter.vue`
- [ ] **Step 1: Create the component**
Create `frontend/components/ui/DateFilter.vue`:
```vue
<template>
<div class="date-filter">
<VueDatePicker
v-model="internalValue"
:range="isRange"
:enable-time-picker="false"
:text-input="textInputConfig"
:locale="'fr'"
:format="formatDate"
:preview-format="formatDate"
auto-apply
:multi-calendars="false"
position="left"
@update:model-value="onUpdate"
@cleared="onClear"
>
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
<div class="relative">
<input
:value="value"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="placeholder || t('common.dateFilter')"
readonly
@click="openMenu"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
/>
<button
v-if="value"
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>
</template>
<template #action-buttons>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import VueDatePicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const isRange = ref(false)
const internalValue = ref<Date | Date[] | null>(null)
const firstClick = ref<Date | null>(null)
const textInputConfig = {
enterSubmit: true,
tabSubmit: true,
format: 'dd/MM/yyyy',
rangeSeparator: ' - ',
}
function formatDate(date: Date | Date[]): string {
if (Array.isArray(date)) {
return date.map(d => formatSingleDate(d)).join(' - ')
}
return formatSingleDate(date)
}
function formatSingleDate(d: Date): string {
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function onUpdate(value: Date | Date[] | null) {
if (value === null) {
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
return
}
if (Array.isArray(value) && value.length === 2) {
emit('update:modelValue', [value[0], value[1]])
return
}
if (value instanceof Date) {
if (firstClick.value === null) {
// First click — select single day, store for potential range
firstClick.value = value
emit('update:modelValue', value)
// Enable range mode for next click
nextTick(() => {
isRange.value = true
})
}
}
}
function onClear() {
internalValue.value = null
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
isRange.value = false
firstClick.value = null
internalValue.value = today
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
isRange.value = true
firstClick.value = null
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
// Sync external modelValue to internal state
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
firstClick.value = null
isRange.value = false
} else if (Array.isArray(val)) {
isRange.value = true
internalValue.value = [...val]
} else {
isRange.value = false
internalValue.value = val
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>
```
- [ ] **Step 2: Verify the component renders**
Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur.
- [ ] **Step 3: Commit**
```bash
git add frontend/components/ui/DateFilter.vue
git commit -m "feat(frontend) : create DateFilter reusable component"
```
---
## Chunk 2: Integration
### Task 4: Integrate DateFilter into time-tracking page
**Files:**
- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar)
- Modify: `frontend/pages/time-tracking.vue:138` (add ref)
- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed)
- [ ] **Step 1: Add the date filter ref**
In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add:
```typescript
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
```
- [ ] **Step 2: Add DateFilter to the template filter bar**
In the filter bar `<div>` (line 15), after the tag MalioSelect block (after line 72), add:
```vue
<DateFilter
v-model="selectedDateFilter"
/>
```
- [ ] **Step 3: Add date filtering to filteredEntries computed**
In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering:
```typescript
const filteredEntries = computed(() => {
let result = entries.value
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result
})
```
- [ ] **Step 4: Verify manually**
Run `make dev-nuxt`, navigate to time-tracking page:
1. Verify DateFilter appears in the filter bar
2. Click a single day — entries filter to that day
3. Click a second day — entries filter to the range
4. Click "Aujourd'hui" — filters to today
5. Click "Cette semaine" — filters to current week
6. Clear the filter — all entries show again
- [ ] **Step 5: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat(frontend) : integrate date filter into time-tracking page"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,802 @@
# User Avatar Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a cropped profile avatar that replaces initials everywhere in the app.
**Architecture:** New `avatarFileName` column on User entity, dedicated upload/serve/delete controllers, `UserAvatar.vue` component with `vue-advanced-cropper` for circular crop, and a `/profile` page for management.
**Tech Stack:** PHP 8.4/Symfony 8, Doctrine migration, `vue-advanced-cropper`, Nuxt 4 SPA
---
## File Structure
### Backend (create)
- `src/Controller/UserAvatarController.php` — upload, serve, delete avatar (3 routes)
### Backend (modify)
- `src/Entity/User.php` — add `avatarFileName` field + `getAvatarUrl()` virtual getter
- `config/services.yaml` — add `avatar_upload_dir` parameter + wire controller
### Frontend (create)
- `frontend/components/user/UserAvatar.vue` — reusable avatar display (image or initials fallback)
- `frontend/components/user/AvatarCropper.vue` — crop modal using `vue-advanced-cropper`
- `frontend/services/avatar.ts` — avatar API service (upload, remove, getUrl)
- `frontend/pages/profile.vue` — profile page with avatar management
### Frontend (modify)
- `frontend/services/dto/user-data.ts` — add `avatarUrl` to `UserData`
- `frontend/stores/auth.ts` — add `refreshUser()` action
- `frontend/components/ui/AppTopNav.vue` — use `UserAvatar` + link "Mon profil" to `/profile`
- `frontend/components/task/TaskCard.vue:47-59` — replace initials with `UserAvatar`
- `frontend/pages/projects/[id]/archives.vue:49-55` — replace initials with `UserAvatar`
- `frontend/components/admin/AdminClientTicketTab.vue:82` — use `UserAvatar` for submitter
- `frontend/middleware/auth.global.ts` — allow `/profile` for all authenticated users
---
## Task 1: Backend — User entity + migration
**Files:**
- Modify: `src/Entity/User.php`
- Create: migration file (generated)
- [ ] **Step 1: Add `avatarFileName` field to User entity**
In `src/Entity/User.php`, add after the `$apiToken` field:
```php
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['me:read', 'user:list'])]
private ?string $avatarFileName = null;
```
Add getter/setter:
```php
public function getAvatarFileName(): ?string
{
return $this->avatarFileName;
}
public function setAvatarFileName(?string $avatarFileName): static
{
$this->avatarFileName = $avatarFileName;
return $this;
}
```
Add virtual `avatarUrl` getter (serialized, read-only):
```php
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
return null;
}
return '/api/users/' . $this->id . '/avatar';
}
```
- [ ] **Step 2: Generate and run migration**
```bash
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:diff
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/User.php migrations/
git commit -m "feat(avatar) : add avatarFileName field to User entity"
```
---
## Task 2: Backend — Avatar controller
**Files:**
- Create: `src/Controller/UserAvatarController.php`
- Modify: `config/services.yaml`
- [ ] **Step 1: Add `avatar_upload_dir` parameter in `config/services.yaml`**
Add to `parameters:` section:
```yaml
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
```
Add service wiring:
```yaml
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
```
- [ ] **Step 2: Create `UserAvatarController.php`**
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
class UserAvatarController extends AbstractController
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $avatarUploadDir,
) {}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function upload(int $id, Request $request): JsonResponse
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
}
$mimeType = $file->getClientMimeType();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
}
// Delete previous avatar file if exists
$this->deleteAvatarFile($user);
$extension = $file->guessExtension() ?? 'bin';
$fileName = Uuid::v4()->toRfc4122() . '.' . $extension;
if (!is_dir($this->avatarUploadDir)) {
mkdir($this->avatarUploadDir, 0o775, true);
}
$file->move($this->avatarUploadDir, $fileName);
$user->setAvatarFileName($fileName);
$this->entityManager->flush();
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function serve(int $id): BinaryFileResponse
{
$user = $this->findUserOrFail($id);
if (null === $user->getAvatarFileName()) {
throw new NotFoundHttpException('No avatar set.');
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('Avatar file not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
$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', 'public, max-age=86400');
return $response;
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function delete(int $id): Response
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$this->deleteAvatarFile($user);
$user->setAvatarFileName(null);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
private function findUserOrFail(int $id): User
{
$user = $this->entityManager->getRepository(User::class)->find($id);
if (null === $user) {
throw new NotFoundHttpException('User not found.');
}
return $user;
}
private function assertCanManageAvatar(User $user): void
{
$currentUser = $this->getUser();
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('You can only manage your own avatar.');
}
}
private function deleteAvatarFile(User $user): void
{
if (null === $user->getAvatarFileName()) {
return;
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (file_exists($filePath)) {
unlink($filePath);
}
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Controller/UserAvatarController.php config/services.yaml
git commit -m "feat(avatar) : add avatar upload/serve/delete controller"
```
---
## Task 3: Frontend — Install vue-advanced-cropper + DTO + service
**Files:**
- Modify: `frontend/services/dto/user-data.ts`
- Create: `frontend/services/avatar.ts`
- Modify: `frontend/stores/auth.ts`
- [ ] **Step 1: Install vue-advanced-cropper**
```bash
cd frontend && npm install vue-advanced-cropper
```
- [ ] **Step 2: Update `UserData` DTO**
In `frontend/services/dto/user-data.ts`, add `avatarUrl` to `UserData`:
```typescript
export type UserData = {
id: number
'@id'?: string
username: string
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
}
```
- [ ] **Step 3: Create `frontend/services/avatar.ts`**
```typescript
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(userId: number): Promise<void> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}
```
- [ ] **Step 4: Add `refreshUser` to auth store**
In `frontend/stores/auth.ts`, add to actions:
```typescript
async refreshUser() {
try {
const me = await getCurrentUser()
this.user = me
} catch {
// Silently fail — user session might have expired
}
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/services/dto/user-data.ts frontend/services/avatar.ts frontend/stores/auth.ts
git commit -m "feat(avatar) : add avatar service, DTO update, and cropper dependency"
```
---
## Task 4: Frontend — UserAvatar component
**Files:**
- Create: `frontend/components/user/UserAvatar.vue`
- [ ] **Step 1: Create `UserAvatar.vue`**
```vue
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/UserAvatar.vue
git commit -m "feat(avatar) : add UserAvatar component with image/initials fallback"
```
---
## Task 5: Frontend — AvatarCropper component
**Files:**
- Create: `frontend/components/user/AvatarCropper.vue`
- [ ] **Step 1: Create `AvatarCropper.vue`**
```vue
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/AvatarCropper.vue
git commit -m "feat(avatar) : add AvatarCropper modal with vue-advanced-cropper"
```
---
## Task 6: Frontend — Profile page
**Files:**
- Create: `frontend/pages/profile.vue`
- Modify: `frontend/middleware/auth.global.ts`
- [ ] **Step 1: Create `frontend/pages/profile.vue`**
```vue
<template>
<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>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@cancel="selectedFile = null"
/>
</div>
</template>
<script setup lang="ts">
const auth = useAuthStore()
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
await upload(auth.user.id, blob)
await auth.refreshUser()
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>
```
- [ ] **Step 2: Allow `/profile` for ROLE_CLIENT in middleware**
In `frontend/middleware/auth.global.ts`, update the client redirect block to also allow `/profile`:
Change:
```typescript
if (!isPortalRoute && !isLoginRoute) {
```
To:
```typescript
const isProfileRoute = to.path === '/profile'
if (!isPortalRoute && !isLoginRoute && !isProfileRoute) {
```
- [ ] **Step 3: Add i18n keys**
In `frontend/i18n/locales/fr.json`, add under a `"profile"` key:
```json
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/profile.vue frontend/middleware/auth.global.ts frontend/i18n/locales/fr.json
git commit -m "feat(avatar) : add profile page with avatar upload and crop"
```
---
## Task 7: Frontend — Replace initials everywhere
**Files:**
- Modify: `frontend/components/ui/AppTopNav.vue`
- Modify: `frontend/components/task/TaskCard.vue`
- Modify: `frontend/pages/projects/[id]/archives.vue`
- Modify: `frontend/components/admin/AdminClientTicketTab.vue`
- [ ] **Step 1: Update `AppTopNav.vue`**
Replace the icon + username display (lines 12-14):
```vue
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
With:
```vue
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
Make "Mon profil" button navigate to `/profile`:
```vue
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
>
Mon profil
</button>
```
- [ ] **Step 2: Update `TaskCard.vue`**
Replace lines 47-59 (the assignee initials span + empty state):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
class="ml-auto"
/>
<span
v-else
class="ml-auto 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>
```
- [ ] **Step 3: Update `archives.vue`**
Replace lines 49-55 (the assignee initials span):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
```
- [ ] **Step 4: Update `AdminClientTicketTab.vue`**
Replace the submitter `<td>` at line 82. The `getSubmitterName` function returns a username string. We need to look up the full user to get `avatarUrl`. Modify the function and display:
Change the `<td>`:
```vue
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
```
Add helper function:
```typescript
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])
return users.value.find(u => u.id === id)
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/components/ui/AppTopNav.vue frontend/components/task/TaskCard.vue frontend/pages/projects/[id]/archives.vue frontend/components/admin/AdminClientTicketTab.vue
git commit -m "feat(avatar) : replace initials with UserAvatar component everywhere"
```
---
## Task 8: Manual testing
- [ ] **Step 1: Rebuild and test**
```bash
make dev-nuxt
```
- [ ] **Step 2: Test flow**
1. Login as `admin` / `admin`
2. Navigate to profile via header dropdown → "Mon profil"
3. Upload an image → verify crop modal appears with circular stencil
4. Confirm crop → verify avatar appears on profile page
5. Check header — avatar should replace the icon
6. Navigate to a project board — assignee cards should show avatar
7. Navigate to archives — same check
8. Go to admin ticket tab — submitter should show avatar + name
9. Remove avatar → verify initials return everywhere
10. Login as `client-liot` / `client` → verify profile page accessible from portal
- [ ] **Step 3: Final commit if any fixes needed**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
# Date Filter Component - Design Spec
## Summary
Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown.
## Behavior
- **Single click** on a day = select that day
- **Second click** on another day = select range between the two dates
- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`)
- **Calendar dropdown**: opens on input click/focus
- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar
- **No time picker**: filter by day granularity only
- **Format**: `dd/MM/yyyy` (French locale)
## Component: `DateFilter.vue`
Location: `frontend/components/ui/DateFilter.vue`
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range |
| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder |
### Emits
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed |
### Implementation
- Wraps `VueDatePicker` with project-consistent styling
- Uses `#dp-input` slot for custom input matching MalioSelect style
- Configures `range` mode with `multi-calendars: false`
- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '`
- Disables time picker (`enable-time-picker: false`)
- Applies project primary color (`#222783`) via CSS overrides
- Responsive width: `!w-44 sm:!w-52`
## Integration: Time Tracking Page
### Filter bar addition
Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters.
### Filtering logic
- Client-side filtering (same pattern as project and tag filters)
- When a single date is selected: show only entries matching that day
- When a range is selected: show entries within the range (inclusive)
- When null: show all entries (no date filter)
## Files Impacted
| File | Action | Description |
|------|--------|-------------|
| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper |
| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` |
| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering |
| `frontend/i18n/locales/fr.json` | Modify | Add French translations |
| `frontend/i18n/locales/en.json` | Modify | Add English translations |
| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency |
## i18n Keys
```json
{
"common": {
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine"
}
}
```
## Style
- Input height and borders match MalioSelect components
- Text size: `text-sm`
- Selected date highlight: project primary color `#222783`
- Calendar dropdown: subtle shadow, rounded corners matching project style
- Override default vue-datepicker CSS variables to match project theme

View File

@@ -0,0 +1,495 @@
# MCP Server for Lesstime — Design Spec
**Date**: 2026-03-15
**Status**: Draft
**Scope**: Expose projects, tasks, and time tracking via MCP for AI clients (Claude Code local first)
## Context
Lesstime is a project management app (Symfony 8 + API Platform 4). We want AI assistants to interact with projects, tasks, and time entries via the Model Context Protocol (MCP).
Both transports are implemented together:
- **STDIO**: Claude Code on the same machine (local dev, `php bin/console mcp:server`)
- **HTTP**: Claude Code or any MCP client on the LAN (`http://<server-ip>:8082/_mcp`), secured by API token
Future: Cloudflare Tunnel for internet-facing access (Claude Web, ChatGPT, Codex).
## Technology Choice
**`symfony/mcp-bundle`** — the official Symfony MCP bundle, maintained by Symfony + PHP Foundation + Anthropic. Uses PHP attributes (`#[McpTool]`) for auto-discovery.
## Architecture
### File Structure
```
src/Mcp/
├── Tool/
│ ├── Project/
│ │ ├── ListProjectsTool.php
│ │ ├── GetProjectTool.php
│ │ ├── CreateProjectTool.php
│ │ └── UpdateProjectTool.php
│ ├── Task/
│ │ ├── ListTasksTool.php
│ │ ├── GetTaskTool.php
│ │ ├── CreateTaskTool.php
│ │ ├── UpdateTaskTool.php
│ │ └── DeleteTaskTool.php
│ ├── TaskMeta/
│ │ ├── ListStatusesTool.php
│ │ ├── ListPrioritiesTool.php
│ │ ├── ListEffortsTool.php
│ │ ├── ListTagsTool.php
│ │ ├── ListGroupsTool.php
│ │ ├── CreateGroupTool.php
│ │ └── UpdateGroupTool.php
│ ├── TimeEntry/
│ │ ├── ListTimeEntriesTool.php
│ │ ├── CreateTimeEntryTool.php
│ │ ├── UpdateTimeEntryTool.php
│ │ └── DeleteTimeEntryTool.php
│ └── Reference/
│ ├── ListUsersTool.php
│ └── ListClientsTool.php
```
### Configuration
```yaml
# config/packages/mcp.yaml
mcp:
app: 'lesstime'
version: '1.0.0'
description: 'Lesstime project management — projects, tasks, time tracking'
instructions: |
This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
Groups are PER-PROJECT (each group belongs to one project).
Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks.
Use list-users and list-clients to discover valid user and client IDs.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600
```
### Nginx Configuration
Add a location block to pass `/_mcp` requests to Symfony (same pattern as `/api`):
```nginx
location /_mcp {
try_files $uri /index.php$is_args$args;
}
```
### Claude Code Configuration
**Option A — Local (STDIO, same machine):**
```json
{
"mcpServers": {
"lesstime": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"],
"cwd": "/home/r-dev/Lesstime"
}
}
}
```
**Option B — Network (HTTP, another machine on LAN):**
```json
{
"mcpServers": {
"lesstime": {
"type": "url",
"url": "http://192.168.x.x:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
}
}
}
```
### Security Model
**STDIO transport**: No authentication. The console command runs locally with full privileges (equivalent to ROLE_ADMIN). Only the local developer has access.
**HTTP transport**: Secured by API token. A new `apiToken` field on the `User` entity stores a unique token per user. A custom Symfony authenticator (`ApiTokenAuthenticator`) checks the `Authorization: Bearer <token>` header on `/_mcp` requests and authenticates as the corresponding user.
#### API Token Implementation
1. **Entity change**: Add `apiToken` (string, unique, nullable) to `User` + Doctrine migration
2. **Authenticator**: `src/Security/ApiTokenAuthenticator.php` — a Symfony custom authenticator that:
- Extracts the token from the `Authorization` header
- Looks up the user by `apiToken`
- Returns 401 if token missing/invalid
3. **Firewall**: New firewall entry in `config/packages/security.yaml` for `/_mcp` path, before the main `api` firewall
4. **Token generation**: A console command `app:generate-api-token <username>` to generate/regenerate tokens
5. **Fixtures**: Add an API token to the admin fixture user for dev/testing
## Tools Specification
### Reference Tools (ID Discovery)
#### `list-users`
- **Description**: List all users (needed to resolve assignee/user IDs)
- **Returns**: Array of `{ id, username }`
- **Implementation**: `UserRepository::findBy([], ['username' => 'ASC'])`
#### `list-clients`
- **Description**: List all clients (needed to resolve client IDs for projects)
- **Returns**: Array of `{ id, name, email }`
- **Implementation**: `ClientRepository::findBy([], ['name' => 'ASC'])`
### Project Tools
#### `list-projects`
- **Description**: List all projects with optional archive filter
- **Parameters**: `archived` (bool, optional, default: false)
- **Returns**: Array of `{ id, code, name, description, color, client: { id, name } | null, archived }`
- **Implementation**: `ProjectRepository::findBy(['archived' => $archived], ['name' => 'ASC'])`
#### `get-project`
- **Description**: Get project details with task count summary per status
- **Parameters**: `id` (int, required)
- **Returns**: `{ id, code, name, description, color, client, archived, taskSummary: { statusLabel: count, ... }, totalTasks }`
- **Implementation**: `ProjectRepository::find($id)` + DQL count query grouped by status
#### `create-project`
- **Description**: Create a new project
- **Parameters**: `name` (string, required), `code` (string, required, 2-10 uppercase letters), `description` (string, optional), `color` (string, optional), `clientId` (int, optional)
- **Returns**: Created project object
- **Implementation**: Create `Project` entity, persist via `EntityManager`
#### `update-project`
- **Description**: Update an existing project (partial update)
- **Parameters**:
- `id` (int, required)
- `name` (string, optional)
- `code` (string, optional)
- `description` (string, optional)
- `color` (string, optional)
- `clientId` (int, optional)
- `archived` (bool, optional)
- **Returns**: Updated project object
- **Implementation**: Find project, apply changes, flush
### Task Tools
#### `list-tasks`
- **Description**: List tasks with filters. Returns max 100 results, use filters to narrow down.
- **Parameters**:
- `projectId` (int, optional) — filter by project
- `statusId` (int, optional) — filter by status
- `assigneeId` (int, optional) — filter by assignee
- `priorityId` (int, optional) — filter by priority
- `groupId` (int, optional) — filter by group
- `tagIds` (int[], optional) — filter by tags
- `archived` (bool, optional, default: false)
- `limit` (int, optional, default: 100, max: 200)
- **Returns**: Array of `{ id, number, title, status: { id, label, color }, priority: { id, label, color } | null, assignee: { id, username } | null, effort: { id, label } | null, group: { id, title } | null, project: { id, code, name }, tags: [{ id, label }], archived }`
- **Implementation**: `TaskRepository` with QueryBuilder, conditional filters, and `setMaxResults($limit)`. Joins must include all relations: status, priority, assignee, project, effort, group, tags.
#### `get-task`
- **Description**: Get full task details
- **Parameters**: `id` (int, required)
- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size, createdAt, uploadedBy: { id, username } }], archived }`
- **Implementation**: `TaskRepository::find($id)` with eager loading
#### `create-task`
- **Description**: Create a new task (number auto-generated per project)
- **Parameters**:
- `projectId` (int, required)
- `title` (string, required)
- `description` (string, optional)
- `statusId` (int, optional)
- `priorityId` (int, optional)
- `effortId` (int, optional)
- `assigneeId` (int, optional)
- `groupId` (int, optional)
- `tagIds` (int[], optional)
- **Returns**: Created task with auto-generated number
- **Implementation**: Create `Task` entity, reuse `TaskRepository::findMaxNumberByProject()` for number generation (same logic as `TaskNumberProcessor`), set relations, persist
#### `update-task`
- **Description**: Update an existing task (partial update, only provided fields are changed)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `description` (string, optional)
- `statusId` (int, optional)
- `priorityId` (int, optional)
- `effortId` (int, optional)
- `assigneeId` (int, optional)
- `groupId` (int, optional)
- `tagIds` (int[], optional)
- `archived` (bool, optional)
- **Returns**: Updated task object
- **Implementation**: Find task, apply changes, flush
#### `delete-task`
- **Description**: Delete a task permanently
- **Parameters**: `id` (int, required)
- **Returns**: `{ success: true, message: "Task PROJECT-123 deleted" }`
- **Implementation**: `EntityManager::remove()` + flush (cascade deletes documents)
### TaskMeta Tools
Statuses, priorities, efforts, and tags are **global** (shared across all projects, read-only via MCP). Groups are **per-project** (read/create/update).
#### `list-statuses`
- **Description**: List all task statuses (needed to create/update tasks)
- **Returns**: Array of `{ id, label, color, position, isFinal }`
- **Implementation**: `TaskStatusRepository::findBy([], ['position' => 'ASC'])`
#### `list-priorities`
- **Description**: List all task priorities
- **Returns**: Array of `{ id, label, color }`
- **Implementation**: `TaskPriorityRepository::findBy([], ['label' => 'ASC'])`
#### `list-efforts`
- **Description**: List all task effort levels
- **Returns**: Array of `{ id, label }`
- **Implementation**: `TaskEffortRepository::findBy([], ['label' => 'ASC'])`
#### `list-tags`
- **Description**: List all task tags
- **Returns**: Array of `{ id, label, color }`
- **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])`
#### `list-groups`
- **Description**: List task groups, optionally filtered by project. Groups are per-project.
- **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false)
- **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }`
- **Implementation**: `TaskGroupRepository` with optional project filter
#### `create-group`
- **Description**: Create a new task group for a project
- **Parameters**:
- `projectId` (int, required)
- `title` (string, required)
- `description` (string, optional)
- `color` (string, optional, default: #222783)
- **Returns**: Created group object
- **Implementation**: Create `TaskGroup` entity, set project relation, persist
#### `update-group`
- **Description**: Update an existing task group (partial update)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `description` (string, optional)
- `color` (string, optional)
- `archived` (bool, optional)
- **Returns**: Updated group object
- **Implementation**: Find group, apply changes, flush
### TimeEntry Tools
#### `list-time-entries`
- **Description**: List time entries with filters
- **Parameters**:
- `userId` (int, optional)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `startDate` (string, optional, format: YYYY-MM-DD)
- `endDate` (string, optional, format: YYYY-MM-DD)
- `limit` (int, optional, default: 100, max: 200)
- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration, user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }`
- **Note**: `duration` is computed from `stoppedAt - startedAt` in minutes. Returns `null` for active timers (stoppedAt is null).
- **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt`
#### `create-time-entry`
- **Description**: Create a time entry
- **Parameters**:
- `userId` (int, required)
- `startedAt` (string, required, ISO 8601)
- `title` (string, optional)
- `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `tagIds` (int[], optional)
- `description` (string, optional)
- **Returns**: Created time entry
- **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null.
#### `update-time-entry`
- **Description**: Update a time entry (e.g., stop a running timer, correct start time)
- **Parameters**:
- `id` (int, required)
- `title` (string, optional)
- `startedAt` (string, optional, ISO 8601)
- `stoppedAt` (string, optional, ISO 8601)
- `projectId` (int, optional)
- `taskId` (int, optional)
- `tagIds` (int[], optional)
- `description` (string, optional)
- **Returns**: Updated time entry
- **Note**: `userId` is intentionally not updatable via MCP. Reassigning time entries to another user should be done through the app UI.
- **Implementation**: Find entry, apply changes, flush
#### `delete-time-entry`
- **Description**: Delete a time entry
- **Parameters**: `id` (int, required)
- **Returns**: `{ success: true, message: "Time entry deleted" }`
- **Implementation**: `EntityManager::remove()` + flush
## Tool Return Format
All tools return JSON strings. For consistency:
- **List tools**: Return a JSON array of objects
- **Get/Create/Update tools**: Return a single JSON object
- **Delete tools**: Return `{ success: true, message: "..." }`
- **Errors**: Throw exceptions (the MCP bundle handles error responses)
- **Duration**: Computed field (minutes), `null` for active timers
Example tool implementation pattern:
```php
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
class ListTasksTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
) {
}
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state')]
public function __invoke(
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
bool $archived = false,
int $limit = 100,
): string {
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t')
->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g')
->leftJoin('t.tags', 'tg')->addSelect('tg')
->where('t.archived = :archived')
->setParameter('archived', $archived)
->orderBy('t.id', 'DESC')
->setMaxResults($limit);
if ($projectId !== null) {
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
}
if ($statusId !== null) {
$qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId);
}
if ($assigneeId !== null) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
}
if ($priorityId !== null) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
}
if ($groupId !== null) {
$qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId);
}
$tasks = $qb->getQuery()->getResult();
// Filter by tags in PHP (ManyToMany not easily filterable in DQL)
if ($tagIds !== null) {
$tasks = array_filter($tasks, function ($task) use ($tagIds) {
$taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray();
return !empty(array_intersect($tagIds, $taskTagIds));
});
}
return json_encode(array_map(fn($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null,
'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null,
'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(),
] : null,
'effort' => $task->getEffort() ? [
'id' => $task->getEffort()->getId(),
'label' => $task->getEffort()->getLabel(),
] : null,
'group' => $task->getGroup() ? [
'id' => $task->getGroup()->getId(),
'title' => $task->getGroup()->getTitle(),
] : null,
'project' => [
'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(),
],
'tags' => $task->getTags()->map(fn($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(),
], array_values($tasks)));
}
}
```
## Installation Steps
1. `composer require symfony/mcp-bundle` (inside Docker container)
2. Create `config/packages/mcp.yaml` with STDIO + HTTP transports
3. Add MCP route: `config/routes/mcp.yaml`
4. Add Nginx location block for `/_mcp`
5. Add `apiToken` field to `User` entity + migration
6. Create `ApiTokenAuthenticator` + security firewall for `/_mcp`
7. Create `app:generate-api-token` console command
8. Update fixtures with API token for admin user
9. Create tool classes in `src/Mcp/Tool/`
10. Test STDIO: `php bin/console mcp:server`
11. Test HTTP: `curl -H "Authorization: Bearer <token>" http://localhost:8082/_mcp`
12. Configure Claude Code settings (STDIO local or HTTP network)
## Future
When ready for internet-facing access:
1. Set up Cloudflare Tunnel for external access
2. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token

View File

@@ -0,0 +1,112 @@
# User Avatar — Design Spec
## Goal
Allow users to upload a profile avatar image (with client-side circular crop) that replaces initials everywhere in the app.
## Backend
### Entity Changes
**User** — add nullable field:
- `avatarFileName: ?string` (length 255) — UUID-based filename stored on disk
### Storage
- Directory: `var/uploads/avatars/`
- Parameter in `services.yaml`: `avatar_upload_dir`
### Endpoints
All under `/api/users/{id}/avatar`:
| Method | Description | Auth |
|--------|-------------|------|
| `POST` | Upload avatar (multipart file) | Owner or ROLE_ADMIN |
| `GET` | Serve avatar image (inline) | ROLE_USER or ROLE_CLIENT |
| `DELETE` | Remove avatar | Owner or ROLE_ADMIN |
**POST** accepts a single `file` field. Validates: image MIME (jpeg, png, webp, gif), max 5 MB. Stores with UUID filename, updates `avatarFileName`. Deletes previous file if exists.
**GET** returns the image with proper `Content-Type`. Returns 404 if no avatar.
**DELETE** removes file from disk, sets `avatarFileName` to null.
These are custom Symfony controllers (not API Platform resources) under `/api/` with `priority: 1`.
### Serialization
Add a virtual `avatarUrl` field to User serialization (group `user:read`):
- If `avatarFileName` is set: `/api/users/{id}/avatar`
- If null: `null`
This way the frontend knows if an avatar exists from any user payload.
### Migration
- Add `avatar_file_name` column (VARCHAR 255, nullable) to `user` table.
## Frontend
### New Components
**`UserAvatar.vue`** (`frontend/components/user/UserAvatar.vue`):
- Props: `user: { id: number, username: string, avatarUrl?: string | null }`, `size: 'xs' | 'sm' | 'md' | 'lg'`
- Sizes: xs=20px, sm=24px, md=32px, lg=48px
- If `avatarUrl`: `<img>` rounded-full, object-cover
- Else: initials badge (current bg-primary-500 style), 2 first chars of username uppercased
- Handles `@error` on img to fallback to initials (broken image)
**`AvatarCropper.vue`** (`frontend/components/user/AvatarCropper.vue`):
- Uses `vue-advanced-cropper` with `CircleStencil`
- Props: `imageFile: File`
- Emits: `crop(blob: Blob)`, `cancel`
- Fixed output size: 256x256px
- Modal overlay with crop area + confirm/cancel buttons
### New Page
**`/profile`** (`frontend/pages/profile.vue`):
- Shows current avatar (large) with "Change" button
- File input triggers AvatarCropper modal
- On confirm: POST blob to `/api/users/{id}/avatar`
- On success: refresh auth store user data
- "Remove avatar" button if avatar exists
- Accessible from "Mon profil" button in AppTopNav dropdown
### New Service
**`frontend/services/avatar.ts`**:
- `upload(userId: number, file: Blob): Promise<void>` — POST multipart
- `remove(userId: number): Promise<void>` — DELETE
- `getUrl(userId: number): string` — returns URL path
### DTO Update
**`UserData`** — add: `avatarUrl?: string | null`
### Replacement Points
Replace initials/icon with `<UserAvatar>` in:
| File | Current display | Size |
|------|----------------|------|
| `TaskCard.vue:48-53` | Initials badge (h-5 w-5) | xs |
| `archives.vue:50-55` | Initials badge (h-5 w-5) | xs |
| `AppTopNav.vue:13` | `mdi:account-circle-outline` icon | md |
| `AdminClientTicketTab.vue` | Username text for submitter | sm |
| `ClientTicketDetailModal.vue` | submittedBy display | sm |
### Auth Store
After avatar upload/delete, re-fetch current user data so `avatarUrl` updates everywhere reactively.
## Dependencies
- `vue-advanced-cropper` — npm install in frontend/
## Out of Scope
- Server-side image processing/resize
- Multiple image formats conversion
- Avatar for clients (entities), only users

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

@@ -10,15 +10,13 @@
input-class="w-full" input-class="w-full"
/> />
<div> <MalioInputText
<MalioInputText v-model="form.tokenId"
v-model="form.tokenId" :label="$t('bookstack.settings.tokenId')"
:label="$t('bookstack.settings.tokenId')" :placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')" input-class="w-full"
input-class="w-full" type="password"
type="password" />
/>
</div>
<div> <div>
<MalioInputText <MalioInputText

View File

@@ -0,0 +1,381 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
</div>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="filterProjectId"
:options="projectOptions"
label="Projet"
:empty-option-label="$t('clientTicket.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option :value="null">{{ $t('clientTicket.allStatuses') }}</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>
<!-- Ticket list -->
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
<th class="px-3 py-3">#</th>
<th class="px-3 py-3">Type</th>
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
<th class="px-3 py-3">Statut</th>
<th class="px-3 py-3">Projet</th>
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
<th class="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="ticket in filteredTickets"
:key="ticket.id"
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
@click="openDetail(ticket)"
>
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
<td class="px-3 py-3">
<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>
</td>
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
<td class="px-3 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</td>
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<button
class="rounded p-1 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 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
@click.stop="openDeleteConfirm(ticket)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="status-modal" appear>
<div class="fixed inset-0 z-50 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-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-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>
</Transition>
</Teleport>
<!-- Delete confirm modal -->
<Teleport v-if="deleteModalOpen" to="body">
<Transition name="status-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="deleteModalOpen = false"
/>
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
<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="deleteModalOpen = false"
>
{{ $t('common.cancel') }}
</button>
<button
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isDeleting"
@click="confirmDelete"
>
Supprimer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Ticket detail modal (read-only) -->
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="detailTicket"
/>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const userService = useUserService()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
// Filters
const filterProjectId = ref<number | null>(null)
const filterStatus = ref<string | null>(null)
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const filteredTickets = computed(() => {
let result = tickets.value
if (filterProjectId.value) {
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
}
if (filterStatus.value) {
result = result.filter(t => t.status === filterStatus.value)
}
return result
})
// Status change modal
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)
// Delete modal
const deleteModalOpen = ref(false)
const deleteTarget = ref<ClientTicket | null>(null)
const isDeleting = ref(false)
// Detail modal
const detailOpen = ref(false)
const detailTicket = ref<ClientTicket | null>(null)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
function getProjectName(iri: string): string {
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 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 id = extractIdFromIri(iri)
if (!id) return undefined
return users.value.find(u => u.id === id)
}
function openDetail(ticket: ClientTicket) {
detailTicket.value = ticket
detailOpen.value = true
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.value = true
}
function openDeleteConfirm(ticket: ClientTicket) {
deleteTarget.value = ticket
deleteModalOpen.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 confirmDelete() {
if (!deleteTarget.value) return
isDeleting.value = true
try {
await clientTicketService.remove(deleteTarget.value.id)
deleteModalOpen.value = false
await loadTickets()
} finally {
isDeleting.value = false
}
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll()
}
async function loadData() {
isLoading.value = true
try {
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
clientTicketService.getAll(),
projectService.getAll(),
userService.getAll(),
])
tickets.value = ticketsResult
projects.value = projectsResult
users.value = usersResult
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.status-modal-enter-active,
.status-modal-leave-active {
transition: opacity 0.2s ease;
}
.status-modal-enter-from,
.status-modal-leave-to {
opacity: 0;
}
</style>

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

@@ -0,0 +1,354 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="ticket-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
v-if="ticket"
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
>
CT-{{ String(ticket.number).padStart(3, '0') }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ $t('portal.ticketDetail') }}
</h2>
</div>
<div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<button
v-if="canEdit && !isEditing"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
@click="startEdit"
>
<Icon name="mdi:pencil-outline" size="16" />
{{ $t('common.edit') }}
</button>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
</div>
<!-- Body -->
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Edit mode -->
<template v-if="isEditing">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.title') }}
</label>
<input
v-model="editForm.title"
type="text"
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"
/>
</div>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.description') }}
</label>
<textarea
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>
<div v-if="ticket.type === 'bug'" class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.url') }}
</label>
<input
v-model="editForm.url"
type="url"
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"
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="cancelEdit"
>
{{ $t('common.cancel') }}
</button>
<button
type="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="isSaving"
@click="saveEdit"
>
{{ $t('common.save') }}
</button>
</div>
</template>
<!-- View mode -->
<template v-else>
<!-- Title -->
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
<!-- Badges -->
<div class="mt-3 flex items-center gap-2">
<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-3 py-1 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<!-- Description -->
<div class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
</div>
<!-- URL (if bug) -->
<div v-if="ticket.url" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
<a
:href="ticket.url"
target="_blank"
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<!-- Status comment -->
<div v-if="ticket.statusComment" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
</div>
<!-- Documents -->
<TaskDocumentList
v-if="localDocuments.length"
:documents="localDocuments"
:is-admin="canEdit"
@preview="openPreview"
@delete="handleDeleteDocument"
/>
<!-- Document preview -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- Upload zone -->
<TaskDocumentUpload
v-if="ticket"
:client-ticket-id="ticket.id"
@uploaded="refreshDocuments"
/>
<!-- Date -->
<p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
modelValue: boolean
ticket: ClientTicket | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isEditing.value = false
isOpen.value = false
}
const auth = useAuthStore()
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
// Edit mode
const isEditing = ref(false)
const isSaving = ref(false)
const editForm = reactive({
title: '',
description: '',
url: '',
})
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const canEdit = computed(() => {
if (!props.ticket) return false
if (isAdmin.value) return true
const status = props.ticket.status
if (status === 'done' || status === 'rejected') return false
const userId = auth.user?.id
if (!userId) return false
const sub = props.ticket.submittedBy
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 { id: number }).id === userId
return false
})
function startEdit() {
if (!props.ticket) return
editForm.title = props.ticket.title
editForm.description = props.ticket.description
editForm.url = props.ticket.url ?? ''
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
}
async function saveEdit() {
if (!props.ticket) return
isSaving.value = true
try {
const data: Record<string, unknown> = {
title: editForm.title,
description: editForm.description,
}
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
isEditing.value = false
emit('refresh')
} finally {
isSaving.value = false
}
}
// Reset edit mode when ticket changes
watch(() => props.ticket?.id, () => {
isEditing.value = false
})
async function handleDeleteDocument(doc: TaskDocument) {
await removeDocument(doc.id)
await refreshDocuments()
}
async function refreshDocuments() {
if (!props.ticket) return
localDocuments.value = await getByTicket(props.ticket.id)
}
// Document list (local copy to allow refresh)
const localDocuments = ref<TaskDocument[]>([])
watch(() => props.ticket?.documents, (docs) => {
localDocuments.value = docs ? [...docs] : []
}, { immediate: true })
// Document preview
const previewDoc = ref<TaskDocument | null>(null)
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
previewDoc.value = doc
}
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = localDocuments.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = localDocuments.value[previewIndex.value + 1]
}
}
</script>
<style scoped>
.ticket-modal-enter-active,
.ticket-modal-leave-active {
transition: opacity 0.2s ease;
}
.ticket-modal-enter-active > div:last-child,
.ticket-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.ticket-modal-enter-from,
.ticket-modal-leave-to {
opacity: 0;
}
.ticket-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.ticket-modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<div>
<!-- Trigger button -->
<button
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
@click="open"
>
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span
v-if="totalCount > 0"
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
>
{{ totalCount }}
</span>
</button>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
<Transition name="ct-panel" appear>
<div class="fixed inset-0 z-50 flex justify-end">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Slide panel -->
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null">{{ $t('clientTicket.allStatuses') }}</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>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="space-y-2">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="rounded-lg border border-neutral-200 bg-white"
>
<!-- Ticket row -->
<div
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
@click="toggleExpand(ticket.id)"
>
<div class="min-w-0 flex-1">
<div class="flex 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 leading-snug">{{ 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 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="16" />
</button>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18"
class="text-neutral-400"
/>
</div>
</div>
<!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 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>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="ct-modal" appear>
<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>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
projectId: number
projectName: string
}>()
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
const tickets = ref<ClientTicket[]>([])
const filterStatus = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const totalCount = computed(() =>
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
)
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)
})
async function loadTickets() {
isLoading.value = true
try {
tickets.value = await clientTicketService.getAll({ project: props.projectId })
} finally {
isLoading.value = false
}
}
function open() {
isOpen.value = true
loadTickets()
}
function close() {
isOpen.value = false
expandedId.value = null
}
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
}
}
</script>
<style scoped>
.ct-panel-enter-active,
.ct-panel-leave-active {
transition: opacity 0.2s ease;
}
.ct-panel-enter-active > div:last-child,
.ct-panel-leave-active > div:last-child {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-panel-enter-from,
.ct-panel-leave-to {
opacity: 0;
}
.ct-panel-enter-from > div:last-child,
.ct-panel-leave-to > div:last-child {
transform: translateX(100%);
}
.ct-modal-enter-active,
.ct-modal-leave-active {
transition: opacity 0.15s ease;
}
.ct-modal-enter-from,
.ct-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"

View File

@@ -0,0 +1,171 @@
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<Transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<h3 class="text-sm font-semibold text-neutral-800">
{{ $t('notification.title') }}
</h3>
<button
v-if="unreadCount > 0"
type="button"
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
@click="handleMarkAllRead"
>
{{ $t('notification.markAllRead') }}
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
</div>
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
{{ $t('notification.empty') }}
</div>
<template v-else>
<button
v-for="notif in notifications"
:key="notif.id"
type="button"
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
:class="{ 'bg-primary-50': !notif.isRead }"
@click="handleClick(notif)"
>
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-neutral-800 truncate">
{{ notif.title }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ notif.message }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ formatRelativeDate(notif.createdAt) }}
</p>
</div>
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Notification } from '~/services/dto/notification'
import { useNotifications } from '~/composables/useNotifications'
const {
unreadCount,
notifications,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
} = useNotifications()
const bellRef = ref<HTMLElement>()
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
fetchNotifications()
}
}
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
if (notif.relatedTicket) {
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
}
async function handleMarkAllRead() {
await markAllAsRead()
}
const { t } = useI18n()
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMin / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMin < 1) return t('notification.timeAgo.now')
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
// Close dropdown when clicking outside
function onClickOutside(event: MouseEvent) {
if (!bellRef.value?.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
startPolling()
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.code" v-model="form.code"
@@ -64,7 +64,7 @@
</div> </div>
</form> </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 <button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600" class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting" :disabled="isSubmitting"
@@ -73,7 +73,21 @@
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" /> <Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }} {{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button> </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> </div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer> </AppDrawer>
</template> </template>
@@ -104,6 +118,7 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
} }
}) })
const { create, update } = useProjectService() const { create, update, remove } = useProjectService()
async function handleSubmit() { async function handleSubmit() {
touched.name = true 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() { async function handleArchiveToggle() {
if (!props.project) return if (!props.project) return
isSubmitting.value = true isSubmitting.value = true

View File

@@ -117,7 +117,7 @@ async function loadItems() {
const [g, t, at] = await Promise.all([ const [g, t, at] = await Promise.all([
groupService.getByProject(props.projectId), groupService.getByProject(props.projectId),
taskService.getByProject(props.projectId), taskService.getByProject(props.projectId),
taskService.getByProjectArchived(props.projectId), taskService.getByProject(props.projectId, true),
]) ])
allGroups.value = g allGroups.value = g
activeTasks.value = t activeTasks.value = t

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

@@ -8,7 +8,25 @@
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span> <div class="flex items-center gap-1">
<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"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
</div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4> <h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div> </div>
<button <button
@@ -36,13 +54,35 @@
> >
{{ tag.label }} {{ tag.label }}
</span> </span>
<!-- Deadline badge -->
<span <span
v-if="task.assignee" v-if="task.deadline"
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white" class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:title="task.assignee.username" :style="{ backgroundColor: deadlineColor }"
:title="task.deadline"
> >
{{ task.assignee.username.substring(0, 2).toUpperCase() }} {{ formatDeadline(task.deadline) }}
</span> </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"
size="xs"
class="ml-auto"
/>
<span <span
v-else v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400" class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
@@ -56,9 +96,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
const props = defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
}>() showProjectColor?: boolean
}>(), {
showProjectColor: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click'): void (e: 'click'): void
@@ -80,6 +123,18 @@ function onPlay() {
timerStore.startFromTask(props.task) 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) { function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move' event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(props.task.id)) event.dataTransfer!.setData('text/plain', String(props.task.id))

View File

@@ -28,12 +28,13 @@
<!-- File info --> <!-- File info -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p> <p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p> <p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
</div> </div>
<!-- Delete button --> <!-- Delete button -->
<button <button
v-if="isAdmin" v-if="isAdmin"
type="button"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block" class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)" @click.stop="$emit('delete', doc)"
> >
@@ -47,6 +48,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
defineProps<{ defineProps<{
documents: TaskDocument[] documents: TaskDocument[]
@@ -72,9 +74,4 @@ function getIconForMime(mimeType: string): string {
return 'heroicons:paper-clip' return 'heroicons:paper-clip'
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script> </script>

View File

@@ -56,7 +56,7 @@
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10"> <div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" /> <Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p> <p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p> <p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
<a <a
:href="downloadUrl" :href="downloadUrl"
download download
@@ -77,6 +77,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
document: TaskDocument | null document: TaskDocument | null
@@ -98,12 +99,6 @@ const downloadUrl = computed(() => props.document ? getDownloadUrl(props.documen
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false) const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf') const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
// Focus overlay for keyboard events // Focus overlay for keyboard events
watch(() => props.document, (doc) => { watch(() => props.document, (doc) => {
if (doc) { if (doc) {

View File

@@ -49,14 +49,15 @@
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId?: number
clientTicketId?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
uploaded: [] uploaded: []
}>() }>()
const { upload: uploadFile } = useTaskDocumentService() const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
@@ -109,7 +110,11 @@ async function processFiles(files: File[]) {
uploads.value.push(state) uploads.value.push(state)
try { try {
await uploadFile(props.taskId, file) if (props.clientTicketId) {
await uploadForTicket(props.clientTicketId, file)
} else if (props.taskId) {
await uploadFile(props.taskId, file)
}
state.uploading = false state.uploading = false
state.progress = 100 state.progress = 100
} catch { } catch {

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> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.title" 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 }} {{ task.project.code }}-{{ task.number }}
</span> </span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900"> <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> </h2>
</div> </div>
<button <button
@@ -35,10 +35,46 @@
<Icon name="mdi:close" size="20" /> <Icon name="mdi:close" size="20" />
</button> </button>
</div> </div>
<!-- Client ticket link -->
<div
v-if="isEditing && task?.clientTicket"
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
>
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
<span class="text-sm font-medium text-blue-700">
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
</span>
<span
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
:class="ticketStatusClass(task.clientTicket.status)"
>
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
</span>
</div>
</div> </div>
<!-- Body --> <!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6"> <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 --> <!-- Title -->
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"
@@ -48,6 +84,20 @@
@blur="touched.title = true" @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 --> <!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect <MalioSelect
@@ -85,6 +135,14 @@
empty-option-label="Aucun groupe" empty-option-label="Aucun groupe"
min-width="w-full" min-width="w-full"
/> />
<MalioSelect
v-if="clientTicketOptions.length"
v-model="form.clientTicketId"
:options="clientTicketOptions"
label="Ticket client"
empty-option-label="Aucun ticket client"
min-width="w-full"
/>
</div> </div>
<!-- Tags --> <!-- Tags -->
@@ -117,7 +175,10 @@
<MalioInputTextArea <MalioInputTextArea
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" :size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
/> />
</div> </div>
@@ -129,7 +190,7 @@
/> />
<TaskDocumentList <TaskDocumentList
v-if="isEditing && task" v-if="isEditing && task"
:documents="documents" :documents="localDocuments"
:is-admin="isAdmin" :is-admin="isAdmin"
@preview="openPreview" @preview="openPreview"
@delete="handleDeleteDocument" @delete="handleDeleteDocument"
@@ -139,7 +200,7 @@
<TaskDocumentPreview <TaskDocumentPreview
:document="previewDoc" :document="previewDoc"
:has-prev="previewIndex > 0" :has-prev="previewIndex > 0"
:has-next="previewIndex < documents.length - 1" :has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null" @close="previewDoc = null"
@prev="prevPreview" @prev="prevPreview"
@next="nextPreview" @next="nextPreview"
@@ -157,6 +218,199 @@
v-if="hasBookStack && isEditing && task" v-if="hasBookStack && isEditing && task"
:task-id="task.id" :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 --> <!-- Footer -->
<div <div
@@ -228,8 +482,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task' import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue' import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
@@ -238,6 +494,9 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/services/dto/project'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -249,6 +508,7 @@ const props = defineProps<{
tags: TaskTag[] tags: TaskTag[]
groups: TaskGroup[] groups: TaskGroup[]
users: UserData[] users: UserData[]
projects?: Project[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -262,12 +522,14 @@ const isOpen = computed({
}) })
function close() { function close() {
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
isOpen.value = false isOpen.value = false
} }
const isEditing = computed(() => !!props.task) const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false) const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning'>('details')
const giteaUrl = ref('') const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService() const { getSettings: getGiteaSettings } = useGiteaService()
@@ -289,10 +551,28 @@ const form = reactive({
assigneeId: null as number | null, assigneeId: null as number | null,
groupId: null as number | null, groupId: null as number | null,
tagIds: [] as number[], 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({ const touched = reactive({
title: false, title: false,
project: false,
}) })
const statusOptions = computed(() => const statusOptions = computed(() =>
@@ -311,8 +591,22 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id }))
) )
const groupOptions = computed(() => const groupOptions = computed(() => {
props.groups.map(g => ({ label: g.title, value: g.id })) 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(() => { const canArchive = computed(() => {
@@ -335,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) { function populateForm(task: Task | null) {
if (task) { if (task) {
form.title = task.title ?? '' form.title = task.title ?? ''
@@ -345,6 +655,43 @@ function populateForm(task: Task | null) {
form.assigneeId = task.assignee?.id ?? null form.assigneeId = task.assignee?.id ?? null
form.groupId = task.group?.id ?? null form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id) 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 { } else {
form.title = '' form.title = ''
form.description = '' form.description = ''
@@ -354,13 +701,52 @@ function populateForm(task: Task | null) {
form.assigneeId = null form.assigneeId = null
form.groupId = null form.groupId = null
form.tagIds = [] 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.title = false
touched.project = false
} }
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task) populateForm(props.task)
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) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
} }
}) })
@@ -370,26 +756,47 @@ watch(() => props.task, (task) => {
} }
}) })
watch(() => props.modelValue, async (open) => {
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
})
const { create, update, remove } = useTaskService() const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService() const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
const { t } = useI18n() const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([])
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 authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
function ticketStatusClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-100 text-blue-700'
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
case 'done': return 'bg-green-100 text-green-700'
case 'rejected': return 'bg-red-100 text-red-700'
default: return 'bg-neutral-100 text-neutral-700'
}
}
const localDocuments = ref<TaskDocument[]>([]) const localDocuments = ref<TaskDocument[]>([])
const documents = computed(() => localDocuments.value)
const previewDoc = ref<TaskDocument | null>(null) const previewDoc = ref<TaskDocument | null>(null)
// Sync documents from task prop when modal opens or task changes // Sync documents from task prop when modal opens or task changes
@@ -404,7 +811,7 @@ async function refreshDocuments() {
const previewIndex = computed(() => { const previewIndex = computed(() => {
if (!previewDoc.value) return -1 if (!previewDoc.value) return -1
return documents.value.findIndex(d => d.id === previewDoc.value!.id) return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
}) })
function openPreview(doc: TaskDocument) { function openPreview(doc: TaskDocument) {
@@ -413,13 +820,13 @@ function openPreview(doc: TaskDocument) {
function prevPreview() { function prevPreview() {
if (previewIndex.value > 0) { if (previewIndex.value > 0) {
previewDoc.value = documents.value[previewIndex.value - 1] previewDoc.value = localDocuments.value[previewIndex.value - 1]
} }
} }
function nextPreview() { function nextPreview() {
if (previewIndex.value < documents.value.length - 1) { if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = documents.value[previewIndex.value + 1] previewDoc.value = localDocuments.value[previewIndex.value + 1]
} }
} }
@@ -462,7 +869,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) { if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string' const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task ? 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}`) { if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop() await timerStore.stop()
} }
@@ -491,7 +898,9 @@ async function handleUnarchive() {
async function handleSubmit() { async function handleSubmit() {
touched.title = true touched.title = true
touched.project = true
if (!form.title.trim()) return if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -503,14 +912,45 @@ async function handleSubmit() {
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null, priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null, assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : 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}`), 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) { if (isEditing.value && props.task) {
await update(props.task.id, payload) savedTask = await update(props.task.id, payload)
} else { } 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') emit('saved')

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template> <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"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="blockEl" 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" :style="blockStyle"
:class="{ 'opacity-40': isDragSource }" :class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)" @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 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>
<div class="px-1.5 py-0.5 h-full overflow-hidden"> <div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Full display: title + project + type dot + duration --> <!-- Top: title + project -->
<template v-if="sizeLevel >= 3"> <div class="min-w-0">
<div class="flex items-center gap-1"> <div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div> <div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span> </div>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div> <!-- Spacer -->
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden"> <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 <span
v-for="tag in entry.tags" v-for="tag in visibleTags"
:key="tag.id" :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 }} {{ tag.label }}
</span> </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> </div>
</template> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
<!-- Medium: title + duration --> <div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
<template v-else-if="sizeLevel === 2"> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div> </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 -->
</div> </div>
<!-- Resize handle bottom (outside block) --> <!-- Resize handle bottom (outside block) -->
@@ -116,10 +117,22 @@ const sizeLevel = computed(() => {
return 0 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 blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const bgColor = props.entry.project?.color ?? '#94a3b8'
const col = props.columnIndex ?? 0 const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1 const total = props.totalColumns ?? 1
@@ -127,13 +140,28 @@ const blockStyle = computed(() => {
const leftPercent = (col / total) * 100 const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100 const widthPercent = (1 / total) * 100
return { const base: Record<string, string> = {
top: `${topPx}px`, top: `${topPx}px`,
height: `${heightPx.value}px`, height: `${heightPx.value}px`,
backgroundColor: bgColor,
left: `calc(${leftPercent}% + ${gapPx}px)`, left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}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 --- // --- Click / Drag detection ---

View File

@@ -1,5 +1,5 @@
<template> <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"> <form class="space-y-4" @submit.prevent="onSubmit">
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -105,19 +105,29 @@
> >
Supprimer Supprimer
</button> </button>
<button <div class="flex gap-2">
type="submit" <button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition" v-if="isEditing"
> type="button"
Enregistrer class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
</button> @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> </div>
</form> </form>
</AppDrawer> </AppDrawer>
</template> </template>
<script setup lang="ts"> <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 { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
@@ -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() { async function onDelete() {
if (!props.entry) return if (!props.entry) return
const { remove } = useTimeEntryService() const { remove } = useTimeEntryService()
@@ -257,7 +287,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) { if (isEditing.value && props.entry) {
await update(props.entry.id, payload) await update(props.entry.id, payload)
} else { } else {
await create(payload as any) await create(payload as TimeEntryWrite)
} }
emit('saved') emit('saved')

View File

@@ -1,13 +1,13 @@
<template> <template>
<div class="space-y-2"> <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"> <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>
<div <div
v-for="entry in sortedEntries" v-for="entry in sortedEntries"
:key="entry.id" :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)" @click="emit('editEntry', entry)"
> >
<!-- Color bar --> <!-- Color bar -->
@@ -18,14 +18,14 @@
<!-- Main info --> <!-- Main info -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="truncate text-sm font-semibold text-neutral-900">
<span class="truncate text-sm font-semibold text-neutral-900"> {{ entry.title || $t('common.untitled') }}
{{ entry.title || 'Sans titre' }} </div>
</span> <div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
<span <span
v-for="tag in entry.tags" v-for="tag in entry.tags"
:key="tag.id" :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 }" :style="{ backgroundColor: tag.color }"
> >
{{ tag.label }} {{ tag.label }}
@@ -56,7 +56,7 @@
<!-- Delete action --> <!-- Delete action -->
<button <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" 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)" @click.stop="emit('deleteEntry', entry)"
> >
<Icon name="mdi:delete-outline" size="18" /> <Icon name="mdi:delete-outline" size="18" />

View File

@@ -1,27 +1,27 @@
<template> <template>
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white"> <div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- Day headers --> <!-- Grid body with sticky header -->
<div <div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg" <!-- 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 class="w-16 shrink-0 border-r border-neutral-200" />
<div <div
v-for="day in days" v-for="day in days"
:key="day.dateStr" :key="'header-' + day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center" 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'"> <div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }} {{ 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>
<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>
</div>
<!-- Grid body --> <!-- Columns -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto"> <div class="relative flex">
<!-- Hour labels --> <!-- Hour labels -->
<div class="w-16 shrink-0"> <div class="w-16 shrink-0">
<div <div
@@ -99,7 +99,7 @@
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }" :style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/> />
<div class="min-w-0"> <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"> <div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }} {{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div> </div>
@@ -134,13 +134,16 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div> <div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div> </div>
</div> </div>
</div> </div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]
startDate: Date startDate: Date
@@ -198,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
// Scroll to current hour on mount // Scroll to current hour on mount
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
if (!calendarEl.value) return if (!gridBodyEl.value) return
const scrollParent = getScrollParent()
if (!scrollParent) return
const now = new Date() const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes() const currentMinutes = now.getHours() * 60 + now.getMinutes()
const calendarTop = calendarEl.value.offsetTop const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3 gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
scrollParent.scrollTop = Math.max(0, scrollTarget)
}) })
}) })
@@ -459,7 +459,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
dragState.value = { dragState.value = {
entryId: entry.id, entryId: entry.id,
entry, entry,
title: entry.title || 'Sans titre', title: entry.title || t('common.untitled'),
color: entry.project?.color ?? '#94a3b8', color: entry.project?.color ?? '#94a3b8',
durationMinutes, durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20), ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),

View File

@@ -1,5 +1,5 @@
<template> <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"> <div class="flex h-full items-center justify-between">
<button <button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden" class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@@ -7,16 +7,38 @@
> >
<Icon name="mdi:menu" size="24" /> <Icon name="mdi:menu" size="24" />
</button> </button>
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12"> <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"> <div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" /> <UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p> <p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100"> <div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button <button
type="button" type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100" class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
> >
Mon profil {{ $t('profile.title') }}
</button> </button>
<button <button
type="button" type="button"
@@ -42,7 +64,14 @@ defineProps<{
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const handleLogout = async () => { 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 auth.logout()
await navigateTo('/login') await navigateTo('/login')
} }

View File

@@ -2,7 +2,7 @@
<Teleport v-if="modelValue" to="body"> <Teleport v-if="modelValue" to="body">
<Transition name="modal" appear> <Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center"> <div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" /> <div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600"> <p class="mt-3 text-sm text-neutral-600">

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

View File

@@ -0,0 +1,174 @@
<template>
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
<VueDatePicker
ref="datepicker"
v-model="internalValue"
:week-picker="mode === 'week'"
:enable-time-picker="false"
:locale="frLocale"
:format="formatDisplay"
auto-apply
:multi-calendars="false"
position="left"
teleport
@update:model-value="onUpdate"
>
<template #trigger>
<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>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import { VueDatePicker } from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
import { fr as frLocale } from 'date-fns/locale'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
pickerMode?: 'day' | 'week'
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = computed(() => props.pickerMode ?? 'week')
const internalValue = ref<Date | Date[] | null>(null)
const displayValue = computed(() => {
if (!internalValue.value) return ''
if (internalValue.value instanceof Date) {
return formatFullDate(internalValue.value)
}
if (Array.isArray(internalValue.value) && internalValue.value.length >= 2) {
const [start, end] = internalValue.value
if (!start || !end) return ''
return `${formatShortDate(start)} - ${formatShortDate(end)}`
}
return ''
})
function formatDisplay(dates: Date | Date[]): string {
if (!dates) return ''
if (dates instanceof Date) return formatFullDate(dates)
if (!Array.isArray(dates)) return ''
const valid = dates.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
if (valid.length === 0) return ''
if (valid.length === 1) return formatFullDate(valid[0])
return `${formatShortDate(valid[0])} - ${formatShortDate(valid[1])}`
}
function formatFullDate(d: Date): string {
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function formatShortDate(d: Date): string {
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${day}/${month}`
}
function onUpdate(value: Date | Date[] | null) {
if (!value) {
emit('update:modelValue', null)
return
}
if (mode.value === 'week' && Array.isArray(value)) {
const valid = value.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
if (valid.length >= 2) {
emit('update:modelValue', [valid[0], valid[1]])
}
} else if (mode.value === 'day' && value instanceof Date) {
emit('update:modelValue', value)
}
}
function onClear() {
internalValue.value = null
datepicker.value?.closeMenu()
emit('update:modelValue', null)
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
internalValue.value = today
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
} else if (Array.isArray(val)) {
internalValue.value = [...val]
} else {
internalValue.value = val
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>

View File

@@ -1,11 +1,11 @@
<template> <template>
<button <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="[ :class="[
timerStore.isRunning timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]' ? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600', : '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'" :title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()" @click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"

View File

@@ -0,0 +1,88 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>

View File

@@ -1,5 +1,5 @@
<template> <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"> <form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText <MalioInputText
v-model="form.username" v-model="form.username"
@@ -36,6 +36,39 @@
</div> </div>
</div> </div>
<div class="mt-4">
<MalioSelect
v-model="form.clientId"
label="Client"
:options="clientOptions"
placeholder="Aucun client"
class="w-full"
@update:model-value="onClientChange"
/>
</div>
<div v-if="form.clientId !== null" class="mt-2">
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
<div class="mt-2 flex flex-col gap-2">
<label
v-for="project in filteredProjects"
:key="project.id"
class="flex items-center gap-2 text-sm text-neutral-700"
>
<input
v-model="form.allowedProjectIds"
type="checkbox"
:value="project.id"
class="rounded border-neutral-300"
/>
{{ project.name }}
</label>
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
Aucun projet pour ce client.
</span>
</div>
</div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <button
type="submit" type="submit"
@@ -52,6 +85,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData, UserWrite } from '~/services/dto/user-data' import type { UserData, UserWrite } from '~/services/dto/user-data'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useClientService } from '~/services/clients'
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<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -68,15 +107,32 @@ const isOpen = computed({
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}) })
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER'] const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
const isEditing = computed(() => !!props.item) const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
const filteredProjects = computed(() => {
if (form.clientId === null) return []
return allProjects.value.filter(
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
)
})
const form = reactive({ const form = reactive({
username: '', username: '',
password: '', password: '',
roles: [] as string[], roles: [] as string[],
clientId: null as number | null,
allowedProjectIds: [] as number[],
}) })
const touched = reactive({ const touched = reactive({
@@ -84,19 +140,45 @@ const touched = reactive({
password: false, password: false,
}) })
watch(() => props.modelValue, (open) => { function onClientChange(value: number | null) {
form.clientId = value
form.allowedProjectIds = []
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
}
}
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
if (props.item) { if (props.item) {
form.username = props.item.username ?? '' form.username = props.item.username ?? ''
form.password = '' form.password = ''
form.roles = [...props.item.roles] form.roles = [...props.item.roles]
form.clientId = props.item.client?.id ?? null
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
} else { } else {
form.username = '' form.username = ''
form.password = '' form.password = ''
form.roles = ['ROLE_USER'] form.roles = ['ROLE_USER']
form.clientId = null
form.allowedProjectIds = []
} }
touched.username = false touched.username = false
touched.password = false touched.password = false
const [loadedClients, loadedProjects] = await Promise.all([
useClientService().getAll(),
useProjectService().getAll({ archived: false }),
])
clients.value = loadedClients
allProjects.value = loadedProjects
} }
}) })
@@ -113,9 +195,13 @@ async function handleSubmit() {
const payload: UserWrite = { const payload: UserWrite = {
username: form.username.trim(), username: form.username.trim(),
roles: form.roles, roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
} }
if (form.password) { if (form.password) {
payload.password = form.password payload.plainPassword = form.password
} }
if (isEditing.value && props.item) { if (isEditing.value && props.item) {

View File

@@ -29,13 +29,14 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
toastSuccessKey?: string toastSuccessKey?: string
} }
export const useApi = (): ApiClient => { let isHandlingUnauthorized = false
export function useApi(): ApiClient {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api' const baseURL = config.public.apiBase || '/api'
const toast = useToast() const toast = useToast()
const auth = useAuthStore() const auth = useAuthStore()
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
const i18n = nuxtApp.$i18n as const i18n = nuxtApp.$i18n as
| { | {
t: (key: string) => string t: (key: string) => string
@@ -45,7 +46,7 @@ export const useApi = (): ApiClient => {
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key) const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false) const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
const extractErrorMessage = (error: unknown, responseData?: unknown): string => { function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') { if (typeof data === 'string') {
@@ -169,20 +170,23 @@ export const useApi = (): ApiClient => {
} }
}) })
const request = <T>( function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string, url: string,
options: ApiFetchOptions<'json'> = {} options: ApiFetchOptions<'json'> = {}
) => { ) {
const needsJsonBody = method === 'POST' || method === 'PUT' const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH' const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined) const headers = new Headers(options.headers as HeadersInit | undefined)
if (needsMergePatch && !headers.has('Content-Type')) { if (!isFormData) {
headers.set('Content-Type', 'application/merge-patch+json') if (needsMergePatch && !headers.has('Content-Type')) {
} else if (needsJsonBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/merge-patch+json')
headers.set('Content-Type', 'application/json') } else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
} }
return client<T>(url, { ...options, method, headers }) return client<T>(url, { ...options, method, headers })

View File

@@ -1,8 +1,8 @@
export const useAppVersion = () => { export function useAppVersion() {
const api = useApi() const api = useApi()
const version = useState<string | null>('app-version', () => null) const version = useState<string | null>('app-version', () => null)
const load = async () => { async function load(): Promise<string | null> {
if (version.value) { if (version.value) {
return version.value return version.value
} }

View File

@@ -0,0 +1,26 @@
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
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> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}

View File

@@ -0,0 +1,48 @@
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
export function useClientTicketHelpers() {
function typeBadgeClass(type: string): string {
switch (type) {
case 'bug': return 'bg-red-500'
case 'improvement': return 'bg-blue-500'
default: return 'bg-neutral-500'
}
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-100 text-blue-700'
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
case 'done': return 'bg-green-100 text-green-700'
case 'rejected': return 'bg-red-100 text-red-700'
default: return 'bg-neutral-100 text-neutral-700'
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function getAvailableStatusTransitions(
current: ClientTicketStatus,
t: (key: string) => string,
): { label: string; value: ClientTicketStatus }[] {
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
}
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
}

View File

@@ -0,0 +1,69 @@
import type { Notification } from '~/services/dto/notification'
import { useNotificationService } from '~/services/notifications'
const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes
export function useNotifications() {
const unreadCount = useState<number>('notification-unread-count', () => 0)
const notifications = useState<Notification[]>('notification-list', () => [])
const isLoading = useState<boolean>('notification-loading', () => false)
const service = useNotificationService()
let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchUnreadCount(): Promise<void> {
try {
unreadCount.value = await service.getUnreadCount()
} catch {
// Silently ignore polling errors
}
}
async function fetchNotifications(): Promise<void> {
isLoading.value = true
try {
notifications.value = await service.getAll()
} finally {
isLoading.value = false
}
}
async function markAsRead(id: number): Promise<void> {
await service.markAsRead(id)
const notif = notifications.value.find(n => n.id === id)
if (notif && !notif.isRead) {
notif.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
async function markAllAsRead(): Promise<void> {
await service.markAllAsRead()
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
}
function startPolling(): void {
fetchUnreadCount()
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
unreadCount,
notifications,
isLoading,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
}
}

View File

@@ -22,43 +22,69 @@
"clients": { "clients": {
"created": "Client créé avec succès.", "created": "Client créé avec succès.",
"updated": "Client mis à jour 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": { "projects": {
"title": "Projets",
"created": "Projet créé avec succès.", "created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.", "updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès.", "deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.", "archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.", "unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivé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": { "taskStatuses": {
"created": "Statut créé avec succès.", "created": "Statut créé avec succès.",
"updated": "Statut mis à jour 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": { "taskEfforts": {
"created": "Effort créé avec succès.", "created": "Effort créé avec succès.",
"updated": "Effort mis à jour 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": { "taskPriorities": {
"created": "Priorité créée avec succès.", "created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour 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": { "taskTags": {
"created": "Tag créé avec succès.", "created": "Tag créé avec succès.",
"updated": "Tag mis à jour 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": { "taskGroups": {
"created": "Groupe créé avec succès.", "created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.", "updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.", "deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé 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": { "taskDocuments": {
"title": "Documents", "title": "Documents",
@@ -78,17 +104,64 @@
"archived": "Ticket archivé avec succès.", "archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.", "unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket", "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": { "users": {
"created": "Utilisateur créé avec succès.", "created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour 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": { "timeEntries": {
"created": "Temps enregistré", "created": "Temps enregistré",
"updated": "Temps modifié", "updated": "Temps modifié",
"deleted": "Temps supprimé" "deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps"
}, },
"archive": { "archive": {
"title": "Archives", "title": "Archives",
@@ -112,7 +185,12 @@
"allEfforts": "Tous les efforts", "allEfforts": "Tous les efforts",
"allAssignees": "Tous", "allAssignees": "Tous",
"noTasks": "Aucune tâche", "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": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -166,7 +244,20 @@
}, },
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",
"loading": "Chargement..." "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",
"clear": "Effacer",
"day": "Jour",
"weekShort": "Sem."
}, },
"gitea": { "gitea": {
"settings": { "settings": {
@@ -213,6 +304,82 @@
"error": "Erreur de connexion à Gitea.", "error": "Erreur de connexion à Gitea.",
"notConfigured": "Gitea non configuré pour ce projet." "notConfigured": "Gitea non configuré pour ce projet."
}, },
"portal": {
"title": "Portail client",
"projects": "Vos projets",
"noProjects": "Aucun projet disponible.",
"openTickets": "tickets ouverts",
"newTicket": "Nouveau ticket",
"ticketDetail": "Détail du ticket",
"backToProject": "Retour au projet",
"submitTicket": "Soumettre le ticket",
"ticketCreated": "Ticket soumis avec succès."
},
"clientTicket": {
"title": "Tickets",
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"updated": "Ticket mis à jour avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
"improvement": "Amélioration",
"other": "Autre"
},
"status": {
"new": "Nouveau",
"in_progress": "En cours",
"done": "Terminé",
"rejected": "Rejeté"
},
"fields": {
"title": "Titre",
"description": "Description",
"url": "URL de la page",
"urlPlaceholder": "https://example.com/page-concernee",
"type": "Type",
"project": "Projet"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
"rejectComment": "Commentaire de rejet",
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
"linkedTicket": "Lié au ticket client CT-{number}",
"description": "Description",
"url": "URL (page concernée)",
"statusComment": "Commentaire de statut",
"statusChanged": "Statut mis à jour",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"linkedTooltip": "Lié au ticket client {number}",
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
"noTickets": "Aucun ticket.",
"allStatuses": "Tous les statuts",
"allProjects": "Tous les projets",
"submittedBy": "Soumis par",
"createdAt": "Créé le",
"adminTab": "Tickets client",
"selectType": "Type de ticket",
"changeStatus": "Changer le statut"
},
"notification": {
"title": "Notifications",
"markAllRead": "Tout marquer comme lu",
"empty": "Aucune notification",
"ticketCreated": "Nouveau ticket client {number}",
"ticketStatusChanged": "Ticket {number} mis à jour",
"timeAgo": {
"now": "À l'instant",
"minutes": "Il y a {n} min",
"hours": "Il y a {n}h",
"days": "Il y a {n}j"
}
},
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
},
"bookstack": { "bookstack": {
"settings": { "settings": {
"title": "Configuration BookStack", "title": "Configuration BookStack",
@@ -235,5 +402,35 @@
"noResults": "Aucun résultat", "noResults": "Aucun résultat",
"empty": "Aucun document lié" "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

@@ -5,7 +5,3 @@
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts">
const { version } = useAppVersion()
</script>

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full', 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 <img
v-if="!sidebarIsCollapsed" v-if="!sidebarIsCollapsed"
src="/malio.png" src="/malio.png"
@@ -26,9 +26,9 @@
/> />
<img <img
v-else v-else
src="/malio.png" src="/LOGO_CARRE.png"
alt="Logo" alt="Logo"
class="h-8 w-8 object-cover object-left" class="w-[46px] h-[55px]"
/> />
<button <button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden" 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 sub
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
/> />
<SidebarLink
:to="`/projects/${currentProjectId}/client-tickets`"
icon="mdi:ticket-outline"
label="Tickets client"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
</template> </template>
<SidebarLink <SidebarLink
to="/time-tracking" to="/time-tracking"
icon="mdi:clock-outline" icon="mdi:calendar-edit-outline"
label="Suivi de temps" label="Suivi de temps"
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
@@ -108,24 +115,26 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" /> <SidebarTimer :collapsed="sidebarIsCollapsed" />
</div> </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> <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> </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> </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" /> <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" /> <div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/> <slot/>
</main> </main>
@@ -148,6 +157,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
const auth = useAuthStore() const auth = useAuthStore()
@@ -211,9 +221,9 @@ async function loadRefData() {
if (refData.loaded) return if (refData.loaded) return
const api = useApi() const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([ const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'), api.get<HydraCollection<UserData>>('/users'),
api.get<any>('/projects'), api.get<HydraCollection<Project>>('/projects'),
api.get<any>('/task_tags'), api.get<HydraCollection<TaskTag>>('/task_tags'),
]) ])
refData.users = extractHydraMembers(usersData) refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData) refData.projects = extractHydraMembers(projectsData)
@@ -242,11 +252,6 @@ function onCompleteSaved() {
timerStore.clearPendingEntry() timerStore.clearPendingEntry()
}) })
} }
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,87 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto" />
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 px-4 pb-6">
<SidebarLink
to="/portal"
icon="mdi:folder-outline"
label="Mes projets"
:collapsed="false"
class="border-t border-secondary-500 pt-6"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
v-if="isAdmin"
to="/"
icon="mdi:shield-crown-outline"
label="Administration"
:collapsed="false"
class="mt-2"
@click="ui.closeMobileSidebar()"
/>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
<p class="font-bold">v {{ version }}</p>
</div>
</aside>
<div class="h-full flex-1 flex flex-col min-h-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">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppVersion } from '~/composables/useAppVersion'
const auth = useAuthStore()
const ui = useUiStore()
const route = useRoute()
const { version } = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>

View File

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

View File

@@ -1,16 +1,25 @@
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore() const auth = useAuthStore()
const isLogin = to.path === '/login' const isLogin = to.path === '/login'
if (!auth.checked) { if (!auth.checked) {
await auth.ensureSession() await auth.ensureSession()
} }
if (!isLogin && !auth.isAuthenticated) { if (!isLogin && !auth.isAuthenticated) {
return navigateTo('/login') return navigateTo('/login')
} }
if (isLogin && auth.isAuthenticated) { const isClientOnly = auth.isAuthenticated
return navigateTo('/') && auth.user?.roles?.includes('ROLE_CLIENT')
} && !auth.user?.roles?.includes('ROLE_ADMIN')
if (isLogin && auth.isAuthenticated) {
return navigateTo(isClientOnly ? '/portal' : '/')
}
const isProfileRoute = to.path === '/profile'
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
return navigateTo('/portal')
}
}) })

View File

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: {enabled: false}, devtools: {enabled: false},
ssr: false, ssr: false,
css: ['~/assets/css/dark.css'],
app: { app: {
baseURL: process.env.NODE_ENV === 'production' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/') ? (process.env.NUXT_PUBLIC_APP_BASE || '/')
@@ -23,14 +24,6 @@ export default defineNuxtConfig({
devServer: { devServer: {
port: 3002, port: 3002,
}, },
nitro: {
devProxy: {
'/api': {
target: 'http://nginx',
changeOrigin: true,
},
},
},
components: [ components: [
{path: '~/components', pathPrefix: false}, {path: '~/components', pathPrefix: false},
], ],
@@ -62,5 +55,8 @@ export default defineNuxtConfig({
}, },
typescript: { typescript: {
strict: true strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
} }
}) })

View File

@@ -12,11 +12,13 @@
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.29", "vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
} }
@@ -541,6 +543,12 @@
"postcss-selector-parser": "^7.0.0" "postcss-selector-parser": "^7.0.0"
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dxup/nuxt": { "node_modules/@dxup/nuxt": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
@@ -1094,6 +1102,68 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5259,6 +5329,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
@@ -5720,6 +5796,62 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vuepic/vue-datepicker": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
"integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@floating-ui/vue": "^1.1.9",
"@vueuse/core": "^14.1.0",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18.12.0"
},
"peerDependencies": {
"vue": ">=3.5.0"
}
},
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -6658,6 +6790,12 @@
"consola": "^3.2.3" "consola": "^3.2.3"
} }
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clipboardy": { "node_modules/clipboardy": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
@@ -7126,6 +7264,16 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/db0": { "node_modules/db0": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -7160,6 +7308,12 @@
} }
} }
}, },
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7437,6 +7591,12 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/easy-bem": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
"license": "MIT"
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -13728,6 +13888,24 @@
} }
} }
}, },
"node_modules/vue-advanced-cropper": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.6",
"debounce": "^1.2.0",
"easy-bem": "^1.0.2"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-bundle-renderer": { "node_modules/vue-bundle-renderer": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",

View File

@@ -16,11 +16,13 @@
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.29", "vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
} }

View File

@@ -29,11 +29,13 @@
<AdminUserTab v-if="activeTab === 'users'" /> <AdminUserTab v-if="activeTab === 'users'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" /> <AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" /> <AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' }) useHead({ title: 'Administration' })
const tabs = [ const tabs = [
@@ -45,6 +47,7 @@ const tabs = [
{ key: 'users', label: 'Utilisateurs' }, { key: 'users', label: 'Utilisateurs' },
{ key: 'gitea', label: 'Gitea' }, { key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' }, { key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
] as const ] as const
type TabKey = typeof tabs[number]['key'] type TabKey = typeof tabs[number]['key']

View File

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

View File

@@ -48,7 +48,6 @@ useHead({
title: 'Connexion' title: 'Connexion'
}) })
const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const {version} = useAppVersion() const {version} = useAppVersion()
@@ -56,14 +55,15 @@ const username = ref('')
const password = ref('') const password = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const handleSubmit = async () => { async function handleSubmit() {
if (isSubmitting.value) return if (isSubmitting.value) return
isSubmitting.value = true isSubmitting.value = true
try { try {
await auth.login(username.value, password.value) await auth.login(username.value, password.value)
await router.push('/') const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
await navigateTo(isClient ? '/portal' : '/')
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }

View File

@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/services/projects'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
useHead({ title: t('myTasks.title') }) useHead({ title: t('myTasks.title') })
@@ -48,9 +50,16 @@ const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null) const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null) const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// Sort
type SortOption = 'default' | 'deadline' | 'scheduledStart'
const sortBy = ref<SortOption>('default')
// View toggle // View toggle
const viewMode = ref<'kanban' | 'list'>('kanban') const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection
const selectedTaskIds = reactive(new Set<number>())
// Modal // Modal
const taskModalOpen = ref(false) const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null) const selectedTask = ref<Task | null>(null)
@@ -152,6 +161,11 @@ async function loadTasks() {
if (selectedTagId.value) { if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${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) 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( watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId], [selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
() => { loadTasks() }, () => { loadTasks() },
) )
@@ -214,42 +228,118 @@ async function onDropBacklog(event: DragEvent) {
} }
// Modal // Modal
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
router.replace({ query: {} })
}
function openTaskEdit(task: Task) { function openTaskEdit(task: Task) {
selectedTask.value = task selectedTask.value = task
taskModalOpen.value = true 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() { async function onSaved() {
await loadTasks() await loadTasks()
} }
onMounted(() => { function toggleTaskSelect(taskId: number) {
loadAll() 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> </script>
<template> <template>
<div> <div class="min-w-0">
<!-- Header + Filters --> <!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3"> <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> <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 <button
class="rounded-lg p-2 transition-colors" 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"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" @click="openTaskCreate"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
> >
<Icon name="mdi:view-column-outline" size="20" /> <Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button> </button>
<button <button
class="rounded-lg p-2 transition-colors" class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" :class="viewMode === 'list'
:title="$t('myTasks.viewList')" ? 'border-primary-500 bg-primary-500 text-white'
@click="viewMode = 'list'" : '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="20" /> <Icon name="mdi:format-list-bulleted" size="20" />
</button> </button>
</div> </div>
</div> </div>
@@ -309,16 +399,27 @@ onMounted(() => {
text-field="text-sm" text-field="text-sm"
text-value="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>
</div> </div>
<!-- Kanban View --> <!-- Kanban View -->
<div v-if="viewMode === 'kanban'"> <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 <div
v-for="status in sortedStatuses" v-for="status in sortedStatuses"
:key="status.id" :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'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
@@ -326,24 +427,27 @@ onMounted(() => {
@drop.prevent="onDropStatus($event, status)" @drop.prevent="onDropStatus($event, status)"
> >
<div <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 }" :style="{ backgroundColor: status.color }"
> >
{{ status.label }} ({{ tasksByStatus(status.id).length }}) {{ status.label }} ({{ tasksByStatus(status.id).length }})
</div> </div>
<div class="flex flex-col gap-3 p-3"> <div class="min-h-0 flex-1 overflow-y-auto p-3">
<TaskCard <div class="flex flex-col gap-3">
v-for="task in tasksByStatus(status.id)" <TaskCard
:key="task.id" v-for="task in tasksByStatus(status.id)"
:task="task" :key="task.id"
@click="openTaskEdit(task)" :task="task"
/> show-project-color
<p @click="openTaskEdit(task)"
v-if="tasksByStatus(status.id).length === 0" />
class="py-4 text-center text-xs text-neutral-400" <p
> v-if="tasksByStatus(status.id).length === 0"
{{ $t('myTasks.noTasks') }} class="py-4 text-center text-xs text-neutral-400"
</p> >
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -363,6 +467,7 @@ onMounted(() => {
v-for="task in backlogTasks" v-for="task in backlogTasks"
:key="task.id" :key="task.id"
:task="task" :task="task"
show-project-color
@click="openTaskEdit(task)" @click="openTaskEdit(task)"
/> />
</div> </div>
@@ -376,49 +481,31 @@ onMounted(() => {
</div> </div>
<!-- List View --> <!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6"> <div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<div <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" v-for="task in tasks"
:key="task.id" :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)" @click="openTaskEdit(task)"
> @toggle-select="toggleTaskSelect"
<div class="min-w-0 flex-1"> />
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
<p <p
v-if="tasks.length === 0 && !isLoading" v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400" class="py-8 text-center text-sm text-neutral-400"
@@ -438,6 +525,7 @@ onMounted(() => {
:tags="tags" :tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups" :groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users" :users="users"
:projects="projects"
@saved="onSaved" @saved="onSaved"
/> />
</div> </div>

View File

@@ -0,0 +1,84 @@
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
</div>
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('portal.noProjects') }}
</div>
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="project in projects"
:key="project.id"
:to="`/portal/projects/${project.id}`"
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
>
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
<p class="mt-2 text-sm text-neutral-500">
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
</p>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
definePageMeta({
layout: 'portal',
})
const { t } = useI18n()
useHead({ title: t('portal.title') })
const auth = useAuthStore()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const projects = ref<Project[]>([])
const tickets = ref<ClientTicket[]>([])
const isLoading = ref(true)
const ticketCountByProject = computed(() => {
const counts: Record<number, number> = {}
for (const ticket of tickets.value) {
if (ticket.status === 'new' || ticket.status === 'in_progress') {
const projectId = extractIdFromIri(ticket.project)
if (projectId) {
counts[projectId] = (counts[projectId] ?? 0) + 1
}
}
}
return counts
})
async function loadData() {
isLoading.value = true
try {
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
projects.value = await projectService.getAll({ archived: false })
} else {
// allowedProjects are embedded objects from /api/me (with me:read group)
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
}
tickets.value = await clientTicketService.getAll()
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -0,0 +1,284 @@
<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">
<div class="min-w-0">
<NuxtLink
to="/portal"
class="text-sm text-neutral-400 hover:text-primary-500"
>
{{ $t('portal.backToProject') }}
</NuxtLink>
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
</div>
<NuxtLink
v-if="isClient"
:to="`/portal/projects/${projectId}/new-ticket`"
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"
>
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
<span class="sm:hidden">+ Ticket</span>
</NuxtLink>
</div>
</div>
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<!-- Kanban board -->
<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="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
>
<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">
{{ col.tickets.length }}
</span>
</div>
<div
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"
@drop.prevent="onDrop(col.status)"
>
<div
v-for="ticket in col.tickets"
:key="ticket.id"
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
:draggable="isAdmin"
@dragstart="onDragStart(ticket)"
@dragend="onDragEnd"
@click="openDetail(ticket)"
>
<div class="flex 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>
</div>
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<p
v-if="col.tickets.length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('clientTicket.noTickets') }}
</p>
</div>
</div>
</div>
<!-- Ticket detail modal -->
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
@refresh="loadTickets"
/>
<!-- Reject comment modal -->
<Teleport v-if="rejectModalOpen" 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="cancelReject" />
<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 class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
<textarea
v-model="rejectComment"
rows="3"
class="mt-3 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"
:placeholder="$t('clientTicket.rejectComment')"
/>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancelReject"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
:disabled="!rejectComment.trim()"
@click="confirmReject"
>
{{ $t('clientTicket.status.rejected') }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
definePageMeta({
layout: 'portal',
})
const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))
useHead({ title: t('portal.title') })
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const auth = useAuthStore()
const tickets = ref<ClientTicket[]>([])
const projectName = ref('')
const isLoading = ref(true)
const detailOpen = ref(false)
const selectedTicket = ref<ClientTicket | null>(null)
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
function statusDotClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-500'
case 'in_progress': return 'bg-yellow-500'
case 'done': return 'bg-green-500'
case 'rejected': return 'bg-red-500'
default: return 'bg-neutral-400'
}
}
const columns = computed(() => allStatuses.map(status => ({
status,
label: t(`clientTicket.status.${status}`),
dotClass: statusDotClass(status),
tickets: tickets.value.filter(tk => tk.status === status),
})))
// Drag & drop (admin only)
const draggedTicket = ref<ClientTicket | null>(null)
const dragOverStatus = ref<ClientTicketStatus | null>(null)
function onDragStart(ticket: ClientTicket) {
draggedTicket.value = ticket
}
function onDragEnd() {
draggedTicket.value = null
dragOverStatus.value = null
}
function onDragOver(status: ClientTicketStatus) {
if (!draggedTicket.value) return
dragOverStatus.value = status
}
function onDragLeave() {
dragOverStatus.value = null
}
async function onDrop(newStatus: ClientTicketStatus) {
dragOverStatus.value = null
const ticket = draggedTicket.value
draggedTicket.value = null
if (!ticket || ticket.status === newStatus) return
// Rejected requires a comment
if (newStatus === 'rejected') {
pendingRejectTicket.value = ticket
rejectComment.value = ''
rejectModalOpen.value = true
return
}
// Optimistic update
const oldStatus = ticket.status
ticket.status = newStatus
try {
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
await loadTickets()
} catch {
ticket.status = oldStatus
}
}
// Reject modal
const rejectModalOpen = ref(false)
const rejectComment = ref('')
const pendingRejectTicket = ref<ClientTicket | null>(null)
function cancelReject() {
rejectModalOpen.value = false
pendingRejectTicket.value = null
rejectComment.value = ''
}
async function confirmReject() {
const ticket = pendingRejectTicket.value
if (!ticket || !rejectComment.value.trim()) return
const oldStatus = ticket.status
ticket.status = 'rejected'
rejectModalOpen.value = false
try {
await clientTicketService.updateStatus(ticket.id, {
status: 'rejected',
statusComment: rejectComment.value.trim(),
})
await loadTickets()
} catch {
ticket.status = oldStatus
}
pendingRejectTicket.value = null
rejectComment.value = ''
}
function openDetail(ticket: ClientTicket) {
selectedTicket.value = ticket
detailOpen.value = true
}
async function loadData() {
isLoading.value = true
try {
const [ticketList, project] = await Promise.all([
clientTicketService.getAll({ project: projectId.value }),
projectService.getById(projectId.value),
])
tickets.value = ticketList
projectName.value = project.name
} finally {
isLoading.value = false
}
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll({ project: projectId.value })
}
onMounted(() => {
loadData()
})
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<NuxtLink
:to="`/portal/projects/${projectId}`"
class="text-sm text-neutral-400 hover:text-primary-500"
>
{{ $t('portal.backToProject') }}
</NuxtLink>
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
</div>
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
<!-- Type -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
<select
v-model="form.type"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
<option value="other">{{ $t('clientTicket.type.other') }}</option>
</select>
</div>
<!-- Title -->
<div class="mt-4">
<MalioInputText
v-model="form.title"
:label="$t('clientTicket.title')"
input-class="w-full"
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
@blur="touched.title = true"
/>
</div>
<!-- Description -->
<div class="mt-4">
<MalioInputTextArea
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>
<!-- URL (only for bug type) -->
<div v-if="form.type === 'bug'" class="mt-4">
<MalioInputText
v-model="form.url"
:label="$t('clientTicket.url')"
input-class="w-full"
/>
</div>
<!-- Document upload (only after ticket is created) -->
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
<p class="text-sm text-neutral-500">
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
Les documents pourront être ajoutés après la soumission du ticket.
</p>
</div>
<!-- Submit -->
<div class="mt-6 flex items-center gap-3">
<NuxtLink
:to="`/portal/projects/${projectId}`"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
>
{{ $t('common.cancel') }}
</NuxtLink>
<button
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
{{ $t('portal.submitTicket') }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import type { ClientTicketType } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
definePageMeta({
layout: 'portal',
})
const route = useRoute()
const { t } = useI18n()
const projectId = computed(() => Number(route.params.id))
useHead({ title: t('portal.newTicket') })
const clientTicketService = useClientTicketService()
const form = reactive({
type: 'bug' as ClientTicketType | string,
title: '',
description: '',
url: '',
})
const touched = reactive({
title: false,
})
const isSubmitting = ref(false)
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
if (!form.description.trim()) return
isSubmitting.value = true
try {
await clientTicketService.create({
type: form.type as ClientTicketType,
title: form.title.trim(),
description: form.description.trim(),
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
project: `/api/projects/${projectId.value}`,
})
await navigateTo(`/portal/projects/${projectId.value}`)
} catch {
// Toast already shown by useApi
} finally {
isSubmitting.value = false
}
}
</script>

101
frontend/pages/profile.vue Normal file
View File

@@ -0,0 +1,101 @@
<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>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@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)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
try {
await upload(auth.user.id, blob)
await auth.refreshUser()
} catch {
// Upload error — $fetch will throw on non-2xx
}
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>

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