Compare commits

..

77 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
148 changed files with 7513 additions and 1469 deletions

6
.env
View File

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

99
.env.example Normal file
View File

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

View File

@@ -51,7 +51,6 @@ jobs:
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \

8
.gitignore vendored
View File

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

10
.idea/.gitignore generated vendored
View File

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

8
.idea/Lesstime.iml generated
View File

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

View File

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

View File

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

8
.idea/modules.xml generated
View File

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

20
.idea/php.xml generated
View File

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

6
.idea/vcs.xml generated
View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",
@@ -26,12 +27,13 @@
"symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
},
@@ -90,8 +92,6 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
"phpunit/phpunit": "^13.0"
}
}

1191
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -73,6 +73,7 @@
v-model="editForm.description"
rows="5"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
style="resize: vertical; min-height: 140px; max-height: 500px"
/>
</div>
@@ -191,7 +192,7 @@
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
@@ -243,7 +244,7 @@ const canEdit = computed(() => {
if (!sub) return false
// submittedBy can be an IRI string or an embedded object
if (typeof sub === 'string') return sub === `/api/users/${userId}`
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
return false
})
@@ -270,7 +271,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as any)
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
isEditing.value = false
emit('refresh')
} finally {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@
{{ task.project.code }}-{{ task.number }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2>
</div>
<button
@@ -56,6 +56,25 @@
<!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6">
<button
v-for="tab in ['details', 'planning']"
:key="tab"
type="button"
class="px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab
? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab as 'details' | 'planning'"
>
{{ $t(`tasks.${tab}Tab`) }}
</button>
</nav>
</div>
<div v-show="activeTab === 'details'">
<!-- Title -->
<MalioInputText
v-model="form.title"
@@ -65,6 +84,20 @@
@blur="touched.title = true"
/>
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect
@@ -142,7 +175,10 @@
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
:size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
/>
</div>
@@ -182,6 +218,199 @@
v-if="hasBookStack && isEditing && task"
:task-id="task.id"
/>
</div>
<div v-show="activeTab === 'planning'" class="space-y-6">
<!-- Dates section -->
<div>
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledStart') }}</label>
<input
v-model="form.scheduledStart"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledEnd') }}</label>
<input
v-model="form.scheduledEnd"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div class="mt-4">
<div class="sm:w-1/2">
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.deadline') }}</label>
<input
v-model="form.deadline"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
<!-- Calendar sync -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.syncToCalendar"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
</label>
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
<Icon
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
size="18"
/>
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
</span>
</div>
</div>
<!-- Recurrence -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.isRecurring"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
</label>
<div v-if="form.isRecurring" class="mt-4 space-y-4">
<!-- Type -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
</select>
</div>
<!-- Interval -->
<MalioInputText
v-model="form.recurrenceInterval"
:label="$t('tasks.planning.interval')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="100"
/>
<!-- Weekly: days of week -->
<div v-if="form.recurrenceType === 'weekly'">
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="day in weekDays"
:key="day.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.recurrenceDaysOfWeek.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="day.value"
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
@change="toggleDay(day.value)"
/>
{{ day.label }}
</label>
</div>
</div>
<!-- Monthly options -->
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
{{ $t('tasks.planning.dayOfMonth') }}
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
{{ $t('tasks.planning.weekOfMonth') }}
</label>
</div>
<MalioInputText
v-if="form.monthlyMode === 'dayOfMonth'"
v-model="form.recurrenceDayOfMonth"
:label="$t('tasks.planning.dayOfMonthLabel')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="31"
/>
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option :value="1">1er</option>
<option :value="2">2ème</option>
<option :value="3">3ème</option>
<option :value="4">4ème</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
</select>
</div>
</div>
</div>
<!-- End of recurrence -->
<div class="space-y-3">
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="never" type="radio" />
{{ $t('tasks.planning.neverEnds') }}
</label>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
{{ $t('tasks.planning.afterOccurrences') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'occurrences'"
v-model="form.recurrenceMaxOccurrences"
type="number"
input-class="w-20"
min="1"
/>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="date" type="radio" />
{{ $t('tasks.planning.onDate') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'date'"
v-model="form.recurrenceEndDate"
type="date"
input-class="w-44"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div
@@ -265,6 +494,9 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/services/dto/project'
const props = defineProps<{
modelValue: boolean
@@ -276,6 +508,7 @@ const props = defineProps<{
tags: TaskTag[]
groups: TaskGroup[]
users: UserData[]
projects?: Project[]
}>()
const emit = defineEmits<{
@@ -296,6 +529,7 @@ function close() {
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning'>('details')
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
@@ -318,10 +552,27 @@ const form = reactive({
groupId: null as number | null,
tagIds: [] as number[],
clientTicketId: null as number | null,
projectId: null as number | null,
scheduledStart: '',
scheduledEnd: '',
deadline: '',
syncToCalendar: false,
isRecurring: false,
recurrenceType: 'daily' as string,
recurrenceInterval: '1',
recurrenceDaysOfWeek: [] as string[],
recurrenceDayOfMonth: '',
monthlyMode: 'dayOfMonth' as string,
recurrenceWeekOfMonth: 1,
recurrenceWeekDay: 'monday' as string,
recurrenceEnd: 'never' as string,
recurrenceMaxOccurrences: '',
recurrenceEndDate: '',
})
const touched = reactive({
title: false,
project: false,
})
const statusOptions = computed(() =>
@@ -340,8 +591,22 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const groupOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id }))
const groupOptions = computed(() => {
let filtered = props.groups.filter(g => !g.archived)
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const canArchive = computed(() => {
@@ -364,6 +629,22 @@ function toggleTag(id: number) {
}
}
const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
{ value: 'thursday', label: t('tasks.planning.days.thu') },
{ value: 'friday', label: t('tasks.planning.days.fri') },
{ value: 'saturday', label: t('tasks.planning.days.sat') },
{ value: 'sunday', label: t('tasks.planning.days.sun') },
])
function toggleDay(day: string) {
const idx = form.recurrenceDaysOfWeek.indexOf(day)
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
else form.recurrenceDaysOfWeek.push(day)
}
function populateForm(task: Task | null) {
if (task) {
form.title = task.title ?? ''
@@ -375,6 +656,42 @@ function populateForm(task: Task | null) {
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
form.syncToCalendar = task.syncToCalendar ?? false
if (task.recurrence) {
form.isRecurring = true
form.recurrenceType = task.recurrence.type
form.recurrenceInterval = String(task.recurrence.interval)
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
if (task.recurrence.maxOccurrences) {
form.recurrenceEnd = 'occurrences'
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
} else if (task.recurrence.endDate) {
form.recurrenceEnd = 'date'
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
} else {
form.recurrenceEnd = 'never'
}
} else {
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
}
} else {
form.title = ''
form.description = ''
@@ -385,18 +702,41 @@ function populateForm(task: Task | null) {
form.groupId = null
form.tagIds = []
form.clientTicketId = null
form.projectId = null
form.scheduledStart = ''
form.scheduledEnd = ''
form.deadline = ''
form.syncToCalendar = false
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
}
touched.title = false
touched.project = false
}
watch(() => props.modelValue, async (open) => {
if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task)
try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
} catch {
const pid = resolvedProjectId.value
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
@@ -419,6 +759,7 @@ watch(() => props.task, (task) => {
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([])
@@ -426,6 +767,22 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
)
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -512,7 +869,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
@@ -541,7 +898,9 @@ async function handleUnarchive() {
async function handleSubmit() {
touched.title = true
touched.project = true
if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true
try {
@@ -553,15 +912,45 @@ async function handleSubmit() {
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
scheduledStart: form.scheduledStart || null,
scheduledEnd: form.scheduledEnd || null,
deadline: form.deadline || null,
syncToCalendar: form.syncToCalendar,
}
let savedTask: Task
if (isEditing.value && props.task) {
await update(props.task.id, payload)
savedTask = await update(props.task.id, payload)
} else {
await create(payload)
savedTask = await create(payload)
}
// Handle recurrence
if (form.isRecurring) {
const recPayload = {
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
interval: parseInt(form.recurrenceInterval) || 1,
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
? parseInt(form.recurrenceDayOfMonth) || null : null,
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
? form.recurrenceWeekOfMonth : null,
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
maxOccurrences: form.recurrenceEnd === 'occurrences'
? parseInt(form.recurrenceMaxOccurrences) || null : null,
}
if (savedTask.recurrence) {
await updateRecurrence(savedTask.recurrence.id, recPayload)
} else {
const recurrence = await createRecurrence(recPayload)
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
}
} else if (isEditing.value && props.task?.recurrence) {
await removeRecurrence(props.task.recurrence.id)
}
emit('saved')

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -105,19 +105,29 @@
>
Supprimer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
<div class="flex gap-2">
<button
v-if="isEditing"
type="button"
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
@click="onDuplicate"
>
Dupliquer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
</div>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
@@ -231,6 +241,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
}
})
async function onDuplicate() {
if (!form.date || !form.startTime || !form.endTime) return
const { create } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISO(form.date, form.startTime),
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
await create(payload as TimeEntryWrite)
emit('saved')
isOpen.value = false
}
async function onDelete() {
if (!props.entry) return
const { remove } = useTimeEntryService()
@@ -257,7 +287,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as any)
await create(payload as TimeEntryWrite)
}
emit('saved')

View File

@@ -1,13 +1,13 @@
<template>
<div class="space-y-2">
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
Aucune activité pour cette période
{{ $t('timeEntries.noEntries') }}
</div>
<div
v-for="entry in sortedEntries"
:key="entry.id"
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
@click="emit('editEntry', entry)"
>
<!-- Color bar -->
@@ -18,14 +18,14 @@
<!-- Main info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || 'Sans titre' }}
</span>
<div class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || $t('common.untitled') }}
</div>
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in entry.tags"
:key="tag.id"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
@@ -56,7 +56,7 @@
<!-- Delete action -->
<button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
title="Supprimer"
:title="$t('common.delete')"
@click.stop="emit('deleteEntry', entry)"
>
<Icon name="mdi:delete-outline" size="18" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,43 +22,69 @@
"clients": {
"created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès."
"deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client",
"editClient": "Modifier un client"
},
"projects": {
"title": "Projets",
"created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivés",
"hideArchived": "Masquer les projets archivés"
"hideArchived": "Masquer les projets archivés",
"noProjects": "Aucun projet trouvé.",
"noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet",
"addProjectShort": "Projet",
"editProject": "Modifier un projet",
"deleteConfirmTitle": "Supprimer le projet",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
},
"taskStatuses": {
"created": "Statut créé avec succès.",
"updated": "Statut mis à jour avec succès.",
"deleted": "Statut supprimé avec succès."
"deleted": "Statut supprimé avec succès.",
"addStatus": "Ajouter un statut",
"editStatus": "Modifier un statut",
"deleteStatus": "Supprimer le statut « {label} »",
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
"moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)"
},
"taskEfforts": {
"created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.",
"deleted": "Effort supprimé avec succès."
"deleted": "Effort supprimé avec succès.",
"addEffort": "Ajouter un effort",
"editEffort": "Modifier un effort"
},
"taskPriorities": {
"created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour avec succès.",
"deleted": "Priorité supprimée avec succès."
"deleted": "Priorité supprimée avec succès.",
"addPriority": "Ajouter une priorité",
"editPriority": "Modifier une priorité"
},
"taskTags": {
"created": "Tag créé avec succès.",
"updated": "Tag mis à jour avec succès.",
"deleted": "Tag supprimé avec succès."
"deleted": "Tag supprimé avec succès.",
"addTag": "Ajouter un tag",
"editTag": "Modifier un tag"
},
"taskGroups": {
"created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès."
"unarchived": "Groupe désarchivé avec succès.",
"addGroup": "Ajouter un groupe",
"editGroup": "Modifier un groupe"
},
"taskDocuments": {
"title": "Documents",
@@ -78,17 +104,64 @@
"archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"addTask": "Ajouter un ticket",
"editTask": "Modifier un ticket",
"detailsTab": "Détails",
"planningTab": "Planification",
"planning": {
"dates": "Dates",
"scheduledStart": "Début planifié",
"scheduledEnd": "Fin planifiée",
"deadline": "Deadline",
"calendar": "Calendrier",
"syncToCalendar": "Envoyer au calendrier Zimbra",
"syncOk": "Synchronisé",
"recurrence": "Récurrence",
"isRecurring": "Tâche récurrente",
"type": "Type",
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel",
"interval": "Intervalle",
"daysOfWeek": "Jours de la semaine",
"days": {
"mon": "Lu",
"tue": "Ma",
"wed": "Me",
"thu": "Je",
"fri": "Ve",
"sat": "Sa",
"sun": "Di"
},
"dayOfMonth": "Jour du mois",
"dayOfMonthLabel": "Jour (1-31)",
"weekOfMonth": "Semaine du mois",
"weekOfMonthLabel": "Semaine",
"dayLabel": "Jour",
"endRecurrence": "Fin de la récurrence",
"neverEnds": "Jamais",
"afterOccurrences": "Après X occurrences",
"occurrences": "Occurrences",
"onDate": "À une date",
"endDate": "Date de fin"
}
},
"users": {
"created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès."
"deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé"
"deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps"
},
"archive": {
"title": "Archives",
@@ -112,7 +185,12 @@
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog"
"backlog": "Backlog",
"createTask": "Créer une tâche",
"sortBy": "Trier par",
"sortDefault": "Par défaut",
"sortDeadline": "Échéance",
"sortScheduledStart": "Date planifiée"
},
"dashboard": {
"title": "Tableau de bord",
@@ -168,7 +246,12 @@
"cancel": "Annuler",
"save": "Enregistrer",
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
"untitled": "Sans titre",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
@@ -319,5 +402,35 @@
"noResults": "Aucun résultat",
"empty": "Aucun document lié"
}
},
"zimbra": {
"settings": {
"title": "Calendrier Zimbra",
"serverUrl": "URL du serveur CalDAV",
"serverUrlPlaceholder": "https://mail.ovh.com",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "user{'@'}domain.com",
"calendarPath": "Chemin du calendrier",
"calendarPathPlaceholder": "/dav/user{'@'}domain.com/Calendar/",
"password": "Mot de passe",
"passwordConfigured": "Mot de passe configuré",
"enabled": "Activer la synchronisation CalDAV",
"save": "Enregistrer",
"saved": "Configuration Zimbra enregistrée",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie",
"testFailed": "Connexion échouée"
}
},
"taskRecurrence": {
"created": "Récurrence créée",
"updated": "Récurrence mise à jour",
"deleted": "Récurrence supprimée"
},
"recurrence": {
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
}
}

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img
v-if="!sidebarIsCollapsed"
src="/malio.png"
@@ -26,9 +26,9 @@
/>
<img
v-else
src="/malio.png"
src="/LOGO_CARRE.png"
alt="Logo"
class="h-8 w-8 object-cover object-left"
class="w-[46px] h-[55px]"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@@ -86,11 +86,18 @@
sub
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/client-tickets`"
icon="mdi:ticket-outline"
label="Tickets client"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
</template>
<SidebarLink
to="/time-tracking"
icon="mdi:clock-outline"
icon="mdi:calendar-edit-outline"
label="Suivi de temps"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
@@ -108,24 +115,26 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex flex-col gap-2 items-center p-4">
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
<button
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="20"
/>
</button>
</div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0">
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
@@ -148,6 +157,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
const auth = useAuthStore()
@@ -211,9 +221,9 @@ async function loadRefData() {
if (refData.loaded) return
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData)

View File

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

View File

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

View File

@@ -27,14 +27,15 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const tabs = [
@@ -44,9 +45,9 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'client-tickets', label: 'Tickets client' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
] as const
type TabKey = typeof tabs[number]['key']

View File

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

View File

@@ -17,6 +17,8 @@ import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
useHead({ title: t('myTasks.title') })
@@ -48,9 +50,16 @@ const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// Sort
type SortOption = 'default' | 'deadline' | 'scheduledStart'
const sortBy = ref<SortOption>('default')
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection
const selectedTaskIds = reactive(new Set<number>())
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
@@ -152,6 +161,11 @@ async function loadTasks() {
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
if (sortBy.value === 'deadline') {
params['order[deadline]'] = 'asc'
} else if (sortBy.value === 'scheduledStart') {
params['order[scheduledStart]'] = 'asc'
}
tasks.value = await taskService.getFiltered(params)
}
@@ -164,9 +178,9 @@ async function loadAll() {
}
}
// Watch filters to reload tasks
// Watch filters and sort to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
() => { loadTasks() },
)
@@ -214,42 +228,118 @@ async function onDropBacklog(event: DragEvent) {
}
// Modal
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
router.replace({ query: {} })
}
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
if (task.project?.code && task.number) {
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
}
}
watch(taskModalOpen, (open) => {
if (!open) {
router.replace({ query: {} })
}
})
async function onSaved() {
await loadTasks()
}
onMounted(() => {
loadAll()
function toggleTaskSelect(taskId: number) {
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId)
} else {
selectedTaskIds.add(taskId)
}
}
function toggleSelectAll(taskList: Task[]) {
if (selectedTaskIds.size === taskList.length) {
selectedTaskIds.clear()
} else {
taskList.forEach(t => selectedTaskIds.add(t.id))
}
}
async function onBulkUpdate(field: string, value: number) {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
const payload: Record<string, unknown> = {}
if (field === 'status') payload.status = `/api/task_statuses/${value}`
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
else if (field === 'group') payload.group = `/api/task_groups/${value}`
await Promise.all(ids.map(id => taskService.update(id, payload)))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkArchive() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkDelete() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.remove(id)))
selectedTaskIds.clear()
await loadTasks()
}
onMounted(async () => {
await loadAll()
const taskParam = route.query.task as string | undefined
if (taskParam) {
const dashIndex = taskParam.lastIndexOf('-')
if (dashIndex > 0) {
const code = taskParam.slice(0, dashIndex)
const num = Number(taskParam.slice(dashIndex + 1))
if (num) {
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
if (task) {
openTaskEdit(task)
}
}
}
}
})
</script>
<template>
<div>
<div class="min-w-0">
<!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<div class="flex items-center gap-2">
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
@click="openTaskCreate"
>
<Icon name="mdi:view-column-outline" size="18" />
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
>
<Icon name="mdi:view-list-outline" size="18" />
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
</div>
</div>
@@ -309,16 +399,27 @@ onMounted(() => {
text-field="text-sm"
text-value="text-sm"
/>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
<select
v-model="sortBy"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
</select>
</div>
</div>
</div>
<!-- Kanban View -->
<div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -326,24 +427,27 @@ onMounted(() => {
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
</div>
</div>
@@ -363,6 +467,7 @@ onMounted(() => {
v-for="task in backlogTasks"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
</div>
@@ -376,57 +481,31 @@ onMounted(() => {
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<div
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<TaskBulkActions
:selected-count="selectedTaskIds.size"
:total-count="tasks.length"
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
:statuses="statuses"
:users="users"
:priorities="priorities"
:efforts="efforts"
:groups="groups"
@toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@bulk-delete="onBulkDelete"
/>
<TaskListItem
v-for="task in tasks"
:key="task.id"
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
:task="task"
show-project-color
:selected="selectedTaskIds.has(task.id)"
@click="openTaskEdit(task)"
>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<div class="flex items-center gap-1.5">
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
</div>
@toggle-select="toggleTaskSelect"
/>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"
@@ -446,6 +525,7 @@ onMounted(() => {
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
:projects="projects"
@saved="onSaved"
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,26 +13,31 @@
</div>
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
{{ currentMonthLabel }}
</h2>
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
<div class="flex shrink-0 items-center gap-1 h-8">
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
{{ currentMonthLabel }}
</h2>
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<div class="flex items-center rounded-full bg-neutral-100 p-1">
<button
v-for="mode in (['week', 'day', 'list'] as const)"
:key="mode"
class="px-3 py-1 text-sm font-semibold transition"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
:class="viewMode === mode
? 'bg-primary-500 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-700'"
@click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
</button>
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<div class="[&>div]:!mt-0">
@@ -70,8 +75,6 @@
text-value="text-sm"
/>
</div>
<DateFilter v-model="selectedDateFilter" />
</div>
</div>
@@ -126,6 +129,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' })
@@ -308,9 +312,9 @@ async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
users.value = extractHydraMembers(usersData)
@@ -333,6 +337,7 @@ onMounted(async () => {
})
watch(viewMode, () => {
selectedDateFilter.value = null
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
loadEntries()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\BookStackConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
class BookStackConfiguration
@@ -16,6 +17,7 @@ class BookStackConfiguration
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]

View File

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

View File

@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
@@ -54,6 +55,27 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class ClientTicket
{
public const string TYPE_BUG = 'bug';
public const string TYPE_IMPROVEMENT = 'improvement';
public const string TYPE_OTHER = 'other';
public const array TYPES = [
self::TYPE_BUG,
self::TYPE_IMPROVEMENT,
self::TYPE_OTHER,
];
public const string STATUS_NEW = 'new';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_DONE = 'done';
public const string STATUS_REJECTED = 'rejected';
public const array STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PROGRESS,
self::STATUS_DONE,
self::STATUS_REJECTED,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -66,6 +88,7 @@ class ClientTicket
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::TYPES)]
private ?string $type = null;
#[ORM\Column(length: 255)]
@@ -78,10 +101,12 @@ class ClientTicket
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::STATUSES)]
private ?string $status = 'new';
#[ORM\Column(type: 'text', nullable: true)]

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\GiteaConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
class GiteaConfiguration
@@ -16,6 +17,7 @@ class GiteaConfiguration
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]

View File

@@ -13,6 +13,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ProjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -41,7 +43,7 @@ class Project
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read'])]
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 10, unique: true)]
@@ -51,7 +53,7 @@ class Project
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read'])]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
@@ -87,6 +89,15 @@ class Project
#[Groups(['project:read', 'project:write'])]
private bool $archived = false;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -216,4 +227,10 @@ class Project
return $this;
}
#[Groups(['project:read'])]
public function getTaskCount(): int
{
return $this->tasks->count();
}
}

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Repository\TaskBookStackLinkRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
@@ -31,6 +32,7 @@ class TaskBookStackLink
private string $title;
#[ORM\Column(length: 500)]
#[Assert\Url]
private string $url;
#[ORM\Column]

View File

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

View File

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

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