Compare commits

...

263 Commits

Author SHA1 Message Date
gitea-actions
913d3b7d93 chore: bump version to v0.4.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m16s
2026-05-20 07:48:17 +00:00
90bf46f598 Merge pull request 'feat(mail) : intégration mail OVH IMAP — boîte partagée, lecture, création/lien tâche' (#5) from feat/mail-integration into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #5
2026-05-20 07:45:31 +00:00
e5c5371c74 Merge branch 'develop' into feat/mail-integration 2026-05-20 07:45:09 +00:00
40a1d737f3 Merge pull request 'feat(help) : centre d'aide in-app /help avec 9 sections' (#4) from feat/in-app-help-doc into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #4
2026-05-20 07:44:43 +00:00
595b8f5be3 Merge pull request 'feat(workflow) : workflows de statuts par projet (kanban custom)' (#3) from feat/project-workflows into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 3m47s
Reviewed-on: #3
2026-05-20 07:44:09 +00:00
a40d11503a feat(mail) : allège la barre d'actions du lecteur de mail
Boutons "Créer une tâche" / "Lier à une tâche" réduits (text-xs, padding compact),
suppression des actions "Marquer comme non lu" et "Marquer important".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:24:00 +02:00
760649170e docs : pointeur CLAUDE.md vers la doc d'intégration mail (reprise)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:22:36 +02:00
b0f05da84a docs(mail) : section "Statut & reprise" — handoff complet (bugs corrigés, points en suspens, commandes) pour reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:22:11 +02:00
c75dfa0371 fix(mail) : synchro multi-dossiers fiable contre OVH
Trois causes racines révélées par une vraie synchro complète (139 dossiers) :
- contrainte UNIQUE globale sur message_id : fausse pour IMAP (un même Message-ID
  existe dans plusieurs dossiers) → violation → fermeture de l'EntityManager →
  cascade qui tuait tous les dossiers suivants. Migration : index simple à la place.
- 139 connexions IMAP (une par dossier) → throttling OVH (failed to authenticate) :
  réutilisation d'une seule connexion (closeConnection() ajouté à l'interface).
- état de connexion corrompu après un dossier en erreur (must be in SELECTED state) :
  reconnexion ciblée après chaque dossier en échec.
- garde anti-cascade : reset du ManagerRegistry + arrêt propre si l'EM se ferme.

Résultat : 456 messages sur 57 dossiers (avant : 188/30 puis crash). Les rares
dossiers à encodage spécial sont skippés proprement et réessayés au cycle suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:21:02 +02:00
c6fa5a534e feat(mail) : arbre des dossiers repliable — chevrons, sous-dossiers masqués par défaut
Seuls les dossiers racine sont affichés au départ ; chevron pour déplier/replier
chaque dossier ayant des sous-dossiers. Le clic sur le nom sélectionne toujours le dossier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:05:00 +02:00
17b5fa2340 fix(mail) : corrige le fetch IMAP réel contre OVH (listMessages cassé)
Quatre bugs révélés en testant contre une vraie boîte OVH (les tests mockaient
le provider, donc jamais exercés) :
- requête sans critère → "BAD parse error: zero-length content" : ajout de whereAll()
- getDate()/getSubject() renvoient des Attribute webklex v6, pas des scalaires : casts explicites
- séquence par défaut ST_MSGN → le peek() de webklex faisait un STORE par numéro de
  séquence rejeté par OVH ("flag could not be removed") : force ST_UID sur toutes les requêtes
- snippet via getTextBody() forçait un fetch de corps par mail (sync 179s + peek) :
  setFetchBody(false) au listing, snippet désormais optionnel

Sync INBOX : 9 messages en 1,6s (avant : échec en 179s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:57:17 +02:00
79d3414824 fix(mail) : aligne le contrat front/back pour le listing et le détail des messages
Le service appelait GET /mail/messages?folder=X (404) au lieu de la vraie route
/mail/folders/{path}/messages. Ajoute aussi une couche de mapping backend→DTO
(messages→items, fromAddress→fromEmail, toAddresses→toRecipients, détail plat→imbriqué)
pour réconcilier la dérive de contrat entre Phase 3 (API) et Phase 4 (DTOs front).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:46:21 +02:00
f313e74c9e fix(mail) : le test de connexion fonctionne même si la config est désactivée + remonte l'erreur IMAP réelle
Le guard enabled dans getClient() bloquait le test de connexion alors que le
workflow naturel est configurer → tester → activer. getClient(requireEnabled)
permet au nouveau testConnection() de se connecter sans exiger enabled=true.
Le controller (ROLE_ADMIN) renvoie désormais le détail de l'erreur pour faciliter
le diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:46:11 +02:00
7a682b4662 docs(mail) : checklist prod + sécurité, guide intégration complet, mention README 2026-05-20 00:59:31 +02:00
d6f430ca35 feat(mail) : clés i18n mail.sidebar.* + mail.admin.* (Phase 7) 2026-05-20 00:58:05 +02:00
4d7ff9be26 feat(mail) : sidebar — lien Messagerie + badge unread + polling lifecycle (start au login, stop au logout) 2026-05-20 00:57:41 +02:00
7c0d3372a9 feat(mail) : intègre onglet Mail dans pages/admin.vue 2026-05-20 00:57:19 +02:00
d36429f058 feat(mail) : AdminMailTab — form IMAP/SMTP/credentials + test connexion + indicateur hasPassword 2026-05-20 00:57:10 +02:00
28b673eec8 docs(mail) : plan détaillé Phase 7 — AdminMailTab, sidebar+badge, polling, doc finale (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:55:27 +02:00
bad292a316 feat(mail) : pages/mail.vue — branche handlers Phase 6 (MailCreateTaskModal + MailLinkTaskModal) 2026-05-20 00:50:31 +02:00
273234626f feat(mail) : onglet Mails dans TaskModal — liste mails liés, bouton lier, MailPickerModal 2026-05-20 00:49:57 +02:00
96c7d902e7 feat(mail) : MailPickerModal — sélection mail depuis dossier courant, liaison taskId 2026-05-20 00:48:37 +02:00
f62c790449 feat(mail) : MailLinkTaskModal — autocomplete tâches, filtre projet, debounce 300ms 2026-05-20 00:47:55 +02:00
13cec9a46a feat(mail) : MailCreateTaskModal — picker projet/groupe/priorité, appel createTaskFromMail 2026-05-20 00:47:08 +02:00
d676fdcb0c feat(mail) : clés i18n Phase 6 — createTaskModal, linkTaskModal, pickerModal, taskTab 2026-05-20 00:46:09 +02:00
bfcf712123 docs(mail) : plan détaillé Phase 6 — modals create/link task + onglet Mails dans TaskModal (8 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:44:26 +02:00
622fcf72c1 feat(mail) : page /mail — layout 3 colonnes, deep-link messageId, refus ROLE_CLIENT 2026-05-20 00:37:03 +02:00
67e73a52d7 feat(mail) : MailMessageViewer — header, body sanitizé DOMPurify, PJ téléchargeables, 4 actions 2026-05-20 00:36:29 +02:00
aa175063dc feat(mail) : MailMessageList — liste paginée infinite scroll, indicateurs lu/étoilé/PJ/date relative 2026-05-20 00:35:47 +02:00
9aa14d38a9 feat(mail) : MailFolderTree — arbre récursif dossiers, badges unread, icônes système 2026-05-20 00:35:19 +02:00
95a98012ad feat(mail) : MailRefreshButton — bouton sync manuel, disabled pendant syncing 2026-05-20 00:34:59 +02:00
535753b189 feat(mail) : composable useSystemFolderLabel — mapping dossiers système IMAP vers i18n + icônes 2026-05-20 00:34:46 +02:00
e710f57c49 feat(mail) : clés i18n mail.* (titres, vides, dossiers système, actions, erreurs) 2026-05-20 00:34:24 +02:00
73f0adc761 docs(mail) : plan détaillé Phase 5 — page /mail 3 colonnes + 4 composants (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:32:34 +02:00
2e0f5b4e30 feat(mail) : store Pinia useMailStore — folders, messages, polling 30s, markRead/markFlagged 2026-05-20 00:26:19 +02:00
33e4e79f8e feat(mail) : service API mail — listFolders/messages/getMessage/markRead/markFlagged/createTask/linkTask/downloadAttachment/triggerSync 2026-05-20 00:25:21 +02:00
bfa155d060 feat(mail) : helper sanitizeMailHtml — DOMPurify + placeholder images distantes 2026-05-20 00:24:30 +02:00
e7224765b1 feat(mail) : types TS DTOs mail (config, folders, messages, attachments) 2026-05-20 00:23:26 +02:00
fe07398059 feat(mail) : install dompurify + types 2026-05-20 00:22:33 +02:00
a440ce267f docs(mail) : plan détaillé Phase 4 — services TS, store Pinia, DOMPurify (6 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:21:50 +02:00
8986f3cb0e feat(mail) : security.yaml - access_control ^/api/mail (IS_AUTHENTICATED_FULLY)
- ajoute la regle ^/api/mail avant ^/api pour expliciter l'authentification requise
- les checks fins ROLE_USER vs ROLE_CLIENT restent dans MailAccessChecker (chaque controller)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:15:49 +02:00
6d420c86e8 feat(mail) : MailSyncTriggerController - POST /api/mail/sync (202 + Messenger async)
- dispatch MailSyncRequested au bus Messenger, retourne 202 immediat
- folderPath optionnel via body JSON pour sync ciblee
- en test : transport in-memory route le message en sync
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:15:25 +02:00
cc46dd915d feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine
- App\Message\MailSyncRequested (optionnel folderPath)
- App\MessageHandler\MailSyncRequestedHandler delegue a MailSyncService::syncFolder ou syncAll
- messenger.yaml : transport async via Doctrine DSN, retry 3x exponentiel, failure transport
- en test : transport in-memory (sync immediat)
- migration Version20260519220000 : cree messenger_messages table (idempotente, IF NOT EXISTS)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:14:47 +02:00
f7f7a07162 feat(mail) : MailAttachmentDownloadController - GET /api/mail/attachments/{id} (stream, disposition: attachment)
- downloadId = base64url(messageDbId:partNumber)
- Content-Disposition: attachment systematique (jamais inline pour eviter XSS via HTML attachments)
- X-Content-Type-Options: nosniff
- filename sanitise via basename pour eviter path traversal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:12:38 +02:00
117175d4b1 feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers
- POST /api/mail/messages/{id}/link-task body {taskId} : cree TaskMailLink (idempotent)
- DELETE /api/mail/messages/{id}/link-task/{taskId} : supprime le lien (204)
- GET /api/tasks/{id}/mails : liste les mails lies a une tache
- securite via MailAccessChecker, tests fonctionnels 401/403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:12:10 +02:00
c7d12f6acd feat(mail) : MailCreateTaskController - POST /api/mail/messages/{id}/create-task
- cree une Task avec titre = subject du mail (max 255 chars)
- utilise findMaxNumberByProjectForUpdate pour numero (advisory lock PG)
- transaction wrapInTransaction pour eviter race conditions
- taskGroupId et priorityId optionnels via body JSON
- cree automatiquement le TaskMailLink (mail <-> tache)
- retourne 201 + taskId/taskNumber/taskTitle/messageId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:10:53 +02:00
f584ed96fa feat(mail) : MailMessageReadController + MailMessageFlagController - POST .../read et .../flag
- POST /api/mail/messages/{id}/read body {read: bool} - synchro IMAP + BDD
- POST /api/mail/messages/{id}/flag body {flagged: bool} - synchro IMAP + BDD
- IMAP-side non bloquant : BDD est mise a jour meme si IMAP fail (resync au prochain cycle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:10:06 +02:00
5ce7693343 feat(mail) : MailMessageDetailController - GET /api/mail/messages/{id} (live IMAP + cache 5 min)
- recupere headers + body + attachments via ImapMailProvider::fetchMessage
- cache Symfony pool cache.app, cle mail_body_{md5(messageId)}, TTL 300s
- attachments serialises sans contenu binaire, avec downloadId base64url(messageDbId:partNumber)
- 503 si IMAP indisponible, 404 si message inconnu
- les tests read/flag ROLE_CLIENT/auth seront ajoutes en Task 10 (route deja existante)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:09:30 +02:00
7fb525595e feat(mail) : MailMessagesListController - GET /api/mail/folders/{path}/messages (pagination cursor)
- MailMessageRepository::findByFolderCursor : pagination cursor sentAt DESC, id DESC
- cursor base64url(sentAt_iso:id), limit max 100
- folderPath URL-encode (requirements: .+ pour supporter les slashes nested)
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:08:18 +02:00
b1d6303afe feat(mail) : MailFoldersListController - GET /api/mail/folders (arbre BDD + unreadCount)
- lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec metadata
- securite via MailAccessChecker : ROLE_USER/ADMIN, refus ROLE_CLIENT pur
- tests fonctionnels 401/403/200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:07:23 +02:00
1c3ba9c33c feat(mail) : MailAccessChecker - verification acces mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)
- ensureCanAccessMail : refuse ROLE_CLIENT pur (sans ROLE_ADMIN)
- ensureIsAdmin : helper pour endpoints config
- service utilise par tous les controllers metier mail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:45 +02:00
412c412cbc feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test
- endpoint ROLE_ADMIN qui teste la connexion IMAP via listFolders
- retourne ok:bool + foldersCount ou error sanitise (pas de leak interne)
- priority:1 obligatoire pour eviter conflit avec route API Platform {id}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:25 +02:00
62e0bf5f11 feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)
- ApiResource MailSettings expose les operations Get + Patch sur /api/mail/configuration
- Provider + Processor relient le DTO a l'entite MailConfiguration (singleton)
- password en write-only (jamais retourne) + hasPassword en lecture
- chiffrement password via TokenEncryptor (sodium)
- securite ROLE_ADMIN sur les deux operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:01 +02:00
696b40ca80 feat(mail) : install symfony/messenger + browser-kit + ENCRYPTION_KEY test (deps Phase 3)
- ajoute symfony/messenger ^8.0 et symfony/doctrine-messenger ^8.0 pour la sync mail async
- ajoute symfony/browser-kit + css-selector en dev pour tests fonctionnels WebTestCase
- ENCRYPTION_KEY ajoutee dans phpunit.dist.xml pour permettre le chiffrement en test
- MESSENGER_TRANSPORT_DSN configure (Doctrine), messenger.yaml minimal (sera enrichi en Task 12)
- fix(orm) : ClientTicket - migre uniqueConstraints en attribut separe (Doctrine ORM 4 deprecation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:05:49 +02:00
cbbc491d69 docs(mail) : plan détaillé Phase 3 — API endpoints, sécurité ROLE_CLIENT, Messenger async (15 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:48:43 +02:00
26fab44dab docs(mail) : guide configuration cron OS pour mail-sync 2026-05-19 23:39:44 +02:00
0028b489e4 feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN 2026-05-19 23:39:21 +02:00
1fb7460f8e feat(mail) : commande app:mail:sync avec options --folder et --dry-run 2026-05-19 23:38:40 +02:00
c47434b502 feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50% 2026-05-19 23:37:31 +02:00
f245863b78 feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder 2026-05-19 23:35:30 +02:00
b546f528df feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface 2026-05-19 23:35:12 +02:00
b5b4288cc0 feat(mail) : DTO MailSyncReport + test unitaire 2026-05-19 23:32:29 +02:00
3a2d8d5bde feat(mail) : install webklex/php-imap + symfony/lock, configure lock store 2026-05-19 23:32:01 +02:00
23191bdab6 docs(mail) : plan détaillé Phase 2 — ImapMailProvider, MailSyncService, commande app:mail:sync (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:29:18 +02:00
5f92cbbf4f feat(mail) : fixture MailConfiguration OVH defaults (disabled) 2026-05-19 23:22:05 +02:00
f80680e874 feat(mail) : MailProviderInterface + MailProviderException 2026-05-19 23:20:58 +02:00
697197864f feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto 2026-05-19 23:20:35 +02:00
0da26ff418 feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link 2026-05-19 23:20:03 +02:00
cd9c16a990 feat(mail) : TaskMailLink entity + repository 2026-05-19 23:17:16 +02:00
0c597bc653 feat(mail) : MailMessage entity + repository 2026-05-19 23:16:52 +02:00
0c80159d7e feat(mail) : MailFolder entity + repository 2026-05-19 23:16:17 +02:00
3cac87aa24 feat(mail) : MailConfiguration entity + repository + singleton test 2026-05-19 23:15:47 +02:00
07b7d054d5 docs(mail) : plan détaillé Phase 1 — entités, repos, migration, DTOs, interface (10 tasks TDD)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:10:36 +02:00
361cc8cfab docs(mail) : master plan d'intégration mail OVH IMAP — 7 phases (foundations, sync, API, services front, UI, intégration tâches, admin)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:49:39 +02:00
930e1a1e37 fix(help) : retire definePageMeta auth (middleware global déjà appliqué) 2026-05-19 21:12:05 +02:00
55301c9c63 feat(help) : centre d'aide in-app — page /help avec sidebar + 9 sections markdown stylées, icône ? dans la topbar 2026-05-19 21:09:19 +02:00
5fb7fbe66c fix(workflow) : M4 - aligne la séquence workflow.id après recréation de l'identity (évite conflit avec row Standard de M1) 2026-05-19 20:59:37 +02:00
c1560468e6 fix(workflow) : WorkflowDrawer - input position natif (MalioInputText n'accepte pas les number) 2026-05-19 20:59:37 +02:00
f86698e7cd docs(workflows) : plan d'implémentation + validations Matthieu sur le spec + gitignore dumps locaux 2026-05-19 20:59:37 +02:00
1fd2c05db3 chore : bump version to v0.4.0 2026-05-19 20:59:37 +02:00
9f179e400d feat(workflow) : MCP - list-statuses projectId + list-workflows + switch-project-workflow + maj descriptions create/update-task 2026-05-19 20:59:12 +02:00
6a37349cf7 feat(workflow) : bulk status désactivé sur sélection multi-projets, scoped au workflow du projet 2026-05-19 20:59:12 +02:00
52b78d6bbc feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer 2026-05-19 20:59:12 +02:00
e6d765f7bb feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut, suppression drag-to-status 2026-05-19 20:59:12 +02:00
5d42009348 feat(workflow) : kanban projet et archives basés sur workflow.statuses du projet 2026-05-19 20:59:12 +02:00
8e4ddf00a8 feat(workflow) : admin UI - WorkflowDrawer + AdminWorkflowTab + remplacement onglet Statuts, suppression composants obsolètes 2026-05-19 20:59:12 +02:00
18bc96082f feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project + service + i18n 2026-05-19 20:59:12 +02:00
6a084489ea feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel 2026-05-19 20:59:12 +02:00
80a41db34f feat(workflow) : protège la suppression d'un workflow lié à des projets (409) 2026-05-19 20:59:12 +02:00
cf94635121 feat(workflow) : valide que task.status appartient au workflow du projet 2026-05-19 20:59:12 +02:00
eec61c089c feat(workflow) : migration M4 - alignement schéma Doctrine (indexes + IDENTITY) 2026-05-19 20:59:12 +02:00
a9f87be8e5 feat(workflow) : listener garantissant un seul workflow isDefault=true 2026-05-19 20:59:12 +02:00
25f2fc4b16 feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés 2026-05-19 20:59:12 +02:00
a21914312a feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
f6a947ec15 feat(workflow) : migration M2 - rattache les statuts existants à Standard + category 2026-05-19 20:59:12 +02:00
03f3c85fd8 feat(workflow) : migration M1 - création table workflow + seed Standard 2026-05-19 20:59:12 +02:00
8a68e0d397 feat(workflow) : ajoute workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
43e6d1aed2 feat(workflow) : ajoute workflow et category sur TaskStatus 2026-05-19 20:59:12 +02:00
a3e3fd6da6 feat(workflow) : ajoute l'entité Workflow et son repository 2026-05-19 20:59:12 +02:00
b8b03048b6 feat(workflow) : ajoute l'enum StatusCategory (5 catégories canoniques) 2026-05-19 20:59:12 +02:00
Matthieu
ba86a71e12 docs(workflows) : ajout note de reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
Matthieu
6a942def3f docs(workflows) : spec workflows de statuts par projet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
gitea-actions
b25be8fd6a chore: bump version to v0.3.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 43s
2026-05-06 13:58:46 +00:00
Matthieu
3e6b0e877a fix(time-tracking) : filtres projet/tag server-side et vue liste au mois
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Pousse les filtres projet et tag a l'API (au lieu d'un filtrage client-side
  partiel sur la page courante) pour eviter les resultats incomplets en cas
  de pagination
- Ajoute les watchers selectedProjectId/selectedTagId qui declenchent un reload
- Mode liste : navigation et plage de chargement passent a 1 mois (au lieu
  d'une fenetre de 7 jours qui rendait le mode liste inutilisable)
- Renomme l'option vide du filtre User en "Tous" (etait "User", ambigu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:18 +02:00
Matthieu
9f3fc05a52 fix(project) : masquer le filtre status en mode kanban
En mode kanban, selectionner un statut dans le filtre Status vidait toutes
les autres colonnes ET le backlog (tasks?.status?.id !== selectedId) : le
filtre etait redondant avec les colonnes et cassait la vue.

Conditionne l'affichage du filtre Status a viewMode === 'list' et reset le
filtre lors du retour en kanban.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:09 +02:00
Matthieu
4c3721b6ac fix(dashboard) : appliquer le filtre user aux KPIs et charts de taches
Avant, seul le KPI "Heures sur la periode" reagissait au filtre Utilisateur ;
"Taches totales", "Mes taches actives" et tous les graphiques tache restaient
inchanges. Le computed tasks ne filtrait que par projet, et myTasks etait
hardcode sur auth.user.id (cf ticket LST40).

Ajoute un effectiveUserId (selectedUser ?? auth.user) et applique le filtre
user a tasks pour propager dans tous les charts et KPIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:02 +02:00
Matthieu
06d733f88e docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 08:49:20 +02:00
gitea-actions
258c6e9c17 chore: bump version to v0.3.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-05-04 18:54:31 +00:00
feffe63019 fix(rich-text) : nettoyer deps TipTap obsolètes et fixer interop CJS
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Le rich text editor étant désormais fourni par @malio/layer-ui, les
dépendances @tiptap/* et tiptap-markdown directes dans Lesstime
(héritage de l'ancien éditeur local) ne servent plus et causaient un
doublon de tiptap-markdown (0.8.10 + 0.9.0) qui faisait planter
l'init Nuxt avec une erreur d'export default sur markdown-it-task-lists.

- Suppression des deps @tiptap/extension-link, @tiptap/extension-placeholder,
  @tiptap/pm, @tiptap/starter-kit, @tiptap/vue-3, tiptap-markdown
- Ajout de markdown-it-task-lists à vite.optimizeDeps.include pour
  forcer Vite à gérer correctement l'interop CJS du module

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:54:18 +02:00
34ba554fba chore : bump @malio/layer-ui à 1.4.8
Inclut les couleurs de texte et surlignage façon Jira dans
<MalioInputRichText> (toolbar étendue avec popover en palette).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:47:17 +02:00
b2cc6e96e1 fix(rich-text) : strip HTML pour les contextes plain-text
Avec MalioInputRichText qui émet désormais du HTML par défaut,
plusieurs points d'affichage rendaient les balises brutes au
lieu du texte. Ajoute un helper stripRichText() (frontend) et
descriptionToPlainText() (backend) pour neutraliser ces cas.

- TimeEntryList : strip avant truncate dans la liste des time
  entries.
- ProjectGroupTab : strip dans la cellule description du
  tableau des groupes.
- CalDavService : strip_tags + html_entity_decode avant injection
  dans le DESCRIPTION VEVENT/VTODO iCal (sinon Outlook/Apple
  Calendar affichaient les <p>...</p> à l'utilisateur).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:55:23 +02:00
2a68d2f9c6 feat(rich-text) : migrer vers MalioInputRichText (layer-ui 1.4.7)
Remplace les éditeurs markdown locaux et les textareas
description par <MalioInputRichText> (TipTap v3 + StarterKit +
tiptap-markdown) du paquet @malio/layer-ui.

Sites migrés :
- TaskModal (description tâche)
- TaskGroupDrawer (description groupe de tâches)
- TimeEntryDrawer (description time entry)
- ClientTicketDetailModal (édition + lecture seule)
- ProjectClientTickets (panneau admin lecture seule)
- new-ticket (formulaire portail client)
- client-tickets (vue admin lecture seule)

Stockage en BDD inchangé : le markdown existant est parsé à
l'ouverture, le composant émet du HTML par défaut sur les
sauvegardes (migration lazy au fil des éditions).

Bumpe @malio/layer-ui de ^1.2.3 à ^1.4.7 et ajoute les
dépendances TipTap utilisées par le composant.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:57 +02:00
2898b22440 fix(infra) : monter nginx.conf comme default.conf
Avant, deux fichiers conf cohabitaient dans /etc/nginx/conf.d/
(default.conf de l'image + lesstime.conf monté), tous deux écoutant
sur :80 server_name localhost. Nginx prenait default.conf
(ordre alphabétique), ce qui faisait répondre 404 à toutes les
requêtes /api/* — donc pas de header CORS, donc le navigateur
remontait une erreur CORS trompeuse côté front.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:43 +02:00
gitea-actions
f1fd80d9ac chore: bump version to v0.3.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m43s
2026-04-10 08:18:54 +00:00
Matthieu
24e3e8e989 fix(ui) : fix code block rendering in markdown preview
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Code blocks (triple backticks) had broken styling because prose-code
styles (light background, padding) were also applied to <code> inside
<pre>, conflicting with the dark pre background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:18:40 +02:00
gitea-actions
47f2ab9cd4 chore: bump version to v0.3.29
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
Auto Tag Develop / tag (push) Successful in 6s
2026-04-09 14:35:49 +00:00
Matthieu
36729f8f61 feat(task) : add markdown preview for task description
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:35:41 +02:00
gitea-actions
30b090852d chore: bump version to v0.3.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-09 12:37:35 +00:00
Matthieu
f0c9568521 feat(infra) : persist logs in prod via named volume
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Add lesstime_logs volume for var/log/ persistence across container
restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:34:00 +02:00
gitea-actions
7c37eb58cb chore: bump version to v0.3.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m16s
2026-04-09 09:20:56 +00:00
Matthieu
7a5b8dabff fix : set app title to Lesstime and remove title switch
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:19:20 +02:00
Matthieu
fef563be06 refactor : replace password inputs with MalioInputPassword component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:17:18 +02:00
Matthieu
e14c707dfd fix : replace native select with MalioSelect for sort filter on my-tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:16:02 +02:00
Matthieu
fa7bb27ef5 feat : include collaborator tasks in dashboard, my-tasks, and project filters
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:57:30 +02:00
Matthieu
21e9d2cab4 feat : show collaborators icon on TaskCard and TaskListItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:57:26 +02:00
Matthieu
00ffcb1cf2 feat : add collaborators multi-select to TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:56:53 +02:00
Matthieu
daba09472f feat : add collaborators to Task DTO
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:55:42 +02:00
Matthieu
f3208a481f feat : add collaborators to all MCP task tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:55:36 +02:00
Matthieu
a46542fcdd feat : add Serializer::users() for collaborators
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:54:33 +02:00
Matthieu
1ae2d9ac2c feat : add task_collaborator migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:54:28 +02:00
Matthieu
e41caa9cfe feat : add collaborators ManyToMany on Task entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:53:53 +02:00
gitea-actions
916f4ae101 chore: bump version to v0.3.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 12:04:40 +00:00
45d389c67f docs : guide de configuration du mode maintenance en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:03:57 +02:00
gitea-actions
7f12332cf6 chore: bump version to v0.3.25
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 12:03:43 +00:00
fe30f03b9f docs : ajout maintenance mode dans la doc de deploiement
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:03:30 +02:00
gitea-actions
fc472d5dad chore: bump version to v0.3.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-03 11:56:09 +00:00
a0a2f27eac fix(infra) : extraire maintenance.html du container au deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:56:02 +02:00
gitea-actions
bd7adec2f0 chore: bump version to v0.3.23
All checks were successful
Build & Push Docker Image / build (push) Successful in 19s
Auto Tag Develop / tag (push) Successful in 5s
2026-04-03 11:54:49 +00:00
9b6386c4ae fix(infra) : root nginx-proxy vers public/ pour maintenance.html
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:54:42 +02:00
gitea-actions
9da1ae7ca1 chore: bump version to v0.3.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-03 11:50:10 +00:00
bc8bed3339 feat(infra) : ajout maintenance mode dans nginx-proxy
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:49:50 +02:00
gitea-actions
3fee678bd2 chore: bump version to v0.3.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-03 11:10:14 +00:00
be720178c2 feat(infra) : add maintenance mode during deployments
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Nginx returns a 503 page when maintenance.on exists. The deploy script
automatically enables/disables maintenance mode around the update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:39 +02:00
gitea-actions
eec0294f3e chore: bump version to v0.3.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 49s
2026-04-03 07:39:34 +00:00
59a1c7956c fix(auth) : allow Enter key to submit login form
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:38:17 +02:00
gitea-actions
e86949a1d7 chore: bump version to v0.3.19
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-02 12:12:10 +00:00
Matthieu
7ca62bfc46 chore(infra) : remove release artefact pipeline and baremetal deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Keep only Docker-based deployment workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:11:58 +02:00
gitea-actions
b60e4ae670 chore: bump version to v0.3.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m7s
Build Release Artefact / build (push) Successful in 1m51s
2026-04-02 10:11:41 +00:00
ace52f8fc5 fix(mcp) : add mcp-sessions dir in prod Dockerfile + add time tracking rule doc
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:59:43 +02:00
1ae9535516 refactor : reorganize infra files into infra/dev and infra/prod
Consolidate Docker, Nginx, and deploy configs from 5 scattered directories
(docker/, deploy/docker/, deploy/nginx/, script/) into a single infra/ tree
with dev/ and prod/ subdirectories. Update all references in docker-compose,
Makefile, CI workflows, Dockerfiles, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:36:10 +02:00
gitea-actions
b50cfb5049 chore: bump version to v0.3.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 19s
Build Release Artefact / build (push) Successful in 2m5s
2026-04-01 10:01:14 +00:00
Matthieu
a5227b9936 fix : use sudo docker and port 8081 in deploy scripts
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:01:05 +02:00
gitea-actions
0d298db797 chore: bump version to v0.3.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 16s
Build Release Artefact / build (push) Successful in 2m2s
2026-04-01 09:24:34 +00:00
Matthieu
cbe71a1f32 fix : use malio-dev registry namespace instead of malio
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-04-01 11:24:26 +02:00
gitea-actions
a8fa8fd7e0 chore: bump version to v0.3.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 58s
Build Release Artefact / build (push) Successful in 2m13s
2026-04-01 09:15:52 +00:00
Matthieu
4aa2abd396 fix : remove COPY templates from Dockerfile.prod (dir does not exist)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:15:43 +02:00
gitea-actions
fa3326e99c chore: bump version to v0.3.14
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
Build Release Artefact / build (push) Successful in 1m54s
2026-04-01 09:07:03 +00:00
Matthieu
21e050ce29 feat : add Docker prodcution deployment
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-01 11:00:10 +02:00
gitea-actions
e480e2821b chore: bump version to v0.3.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 2m44s
2026-03-27 13:32:33 +00:00
Matthieu
2d7e9b9226 fix : use label instead of text for MalioSelect options in export drawer
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:20 +01:00
Matthieu
93e0c4052c chore : bump version to v0.3.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 3m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:33:42 +01:00
Matthieu
22373a0b87 refactor : migrate UI to Malio layer-ui components (MalioButton, MalioDrawer, MalioSelectCheckbox)
- Replace all AppDrawer with MalioDrawer across 10 drawer components
- Replace native <button> with MalioButton/MalioButtonIcon in all pages and components
- Fix TimeTrackingExportDrawer: use MalioSelectCheckbox for multi-select filters
- Add Malio design system colors (m-btn-*, m-disabled, m-surface) to tailwind.config.ts
- Align toggle button heights with MalioButton (h-[40px])

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:33:28 +01:00
gitea-actions
d7968af525 chore: bump version to v0.3.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m49s
2026-03-25 17:42:21 +00:00
df2a48c20d fix : remove double /api prefix in export URL
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
7f1c02256b fix : replace MalioButton with styled native button in export drawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
fdc9b8b60d fix : use correct useToast() API in export handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
1025fed0d1 feat : integrate export drawer with async background download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
0331d94ca5 feat : add TimeTrackingExportDrawer component with filters and period presets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
755c39a0f6 feat : extend export endpoint for multi-user, multi-project, client filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
8f8eeddd91 feat : add downloadExport async method to time-entries service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
548b101d82 feat : add i18n keys for export modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
Matthieu
e3149f8a27 chore : bump version to v0.3.10 and add push-tickets-lesstime skill
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:36:54 +01:00
gitea-actions
32aff3d4d3 chore: bump version to v0.3.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 2m6s
2026-03-24 20:06:10 +00:00
Matthieu
9760de1805 feat : add export button to time-tracking page
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:16:06 +01:00
Matthieu
f72dd57bd0 feat : add getExportUrl to time-entries service and i18n key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:15:04 +01:00
Matthieu
a8f7c77758 feat : add TimeEntryExportController with auth, validation, and filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:03:35 +01:00
Matthieu
a09a415393 feat : add TimeEntryExportService generating XLSX with detail and recap sheets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:02:18 +01:00
Matthieu
8208df1ade feat : add findForExport repository method for time entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:00:22 +01:00
Matthieu
15af8975f0 chore : add phpoffice/phpspreadsheet dependency for time entry export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:59:30 +01:00
Matthieu
040cbfc588 docs : add time entry export implementation plan (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:54:06 +01:00
Matthieu
e796741dd8 docs : add time entry export design spec (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:47:33 +01:00
Matthieu
9e7d196443 chore : bump version to v0.3.8
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:20:57 +01:00
Matthieu
3e9a0c93eb fix(admin) : embed client and project in user list serialization
Client.id/name and Project.id/name were missing the user:list group,
causing them to be serialized as IRI strings instead of embedded objects.
This broke the user edit form which expected object properties.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
gitea-actions
4074457499 chore: bump version to v0.2.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-18 10:08:03 +00:00
Matthieu
b29b4d304d fix(user) : clear allowedProjects when removing ROLE_CLIENT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Prevents sending /api/projects/undefined when saving a user after
removing client role. Also auto-clears client and projects when
ROLE_CLIENT checkbox is unchecked.

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

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

Ticket: LST-29

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

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

Ticket: LST-28

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

Ticket: T-023

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

Ticket: T-020

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

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

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

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

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

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

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

Ticket: T-009

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

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

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

Tickets: T-002, T-007

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:02 +01:00
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
322 changed files with 37401 additions and 2585 deletions

View File

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

View File

@@ -0,0 +1,61 @@
# Ticket Executor - Learnings
## Session 2026-03-17 (26 tickets)
### T-001 — Secrets .env
- **Pattern**: Replace secrets with `change_me_in_env_local` placeholder, move real values to `.env.local`
- **Gotcha**: `.env.local` must contain ALL overridden secrets
### T-002 — Security API Gitea
- **Pattern**: Ajouter `security: "is_granted('ROLE_USER')"` sur les opérations ApiResource
- **Learning**: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer
### T-003 — SVG Upload
- **Pattern**: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
- **Learning**: Toujours vérifier upload ET download controllers
### T-004 — MCP create-task / Repos numérotation
- **Gotcha critique**: PostgreSQL n'autorise PAS `FOR UPDATE` avec des fonctions d'agrégation (`MAX`)
- **Fix**: Utiliser `pg_advisory_xact_lock()` au lieu de `FOR UPDATE` pour les queries avec agrégation
- **Pattern**: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket
### T-005 — Filter ROLE_CLIENT projects
- **Pattern**: Créer une Doctrine Extension (`QueryCollectionExtensionInterface` + `QueryItemExtensionInterface`) pour filtrer par relation
- **Learning**: Symfony autoconfigure enregistre l'extension automatiquement
### T-006 — Block client doc upload
- **Pattern**: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
- **Learning**: Le portail client envoie un `clientTicket` IRI (pas de `task` IRI), donc le check sur `taskIri` non-vide suffit
### T-007 — MCP role checks
- **Pattern**: Injecter `Security` dans chaque Tool, vérifier au début de `__invoke()`
- **Learning**: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)
### T-009 — Password hashing
- **Pattern**: Champ `plainPassword` non-persisté, writable uniquement, hashé dans le Processor
- **Learning**: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API
### T-010 — Rate limiting
- **Gotcha**: `login_throttling` nécessite `symfony/rate-limiter` installé, pas juste dans composer.json
- **Learning**: Toujours vérifier que les packages sont installés, pas juste déclarés
### T-012 — Harmoniser repos numérotation
- **Pattern**: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
- **Learning**: Vérifier TOUS les appelants d'une méthode renommée
### T-015 — useAvatarService
- **Learning**: Quand on migre vers `useApi()`, ajouter la détection FormData pour ne pas écraser le Content-Type multipart
### T-020 — i18n
- **Pattern**: Ajouter `useI18n()` dans le setup script avant de pouvoir utiliser `t()` dans le JS
- **Learning**: Les templates peuvent utiliser `$t()` directement sans import
### T-022 — Retirer twig-bundle
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min

View File

@@ -0,0 +1,78 @@
---
name: ticket-executor
description: Execute Lesstime project tickets systematically - updates MCP statuses, follows project conventions, and logs learnings for self-improvement
---
# Ticket Executor Skill
## Purpose
Execute Lesstime project tickets end-to-end: read the ticket, implement the fix, update MCP status, and log learnings.
## Workflow
### 1. Receive Ticket
- Get ticket ID, title, description, tags (Backend/Frontend), priority, and current status
- Understand the scope from the title and description
### 2. Set Status to "En cours" (ID: 2)
- Use MCP `update-task` with `statusId: 2` before starting work
- MCP endpoint: `http://project.malio-dev.fr/_mcp`
- Auth: `Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64`
### 3. Analyze & Implement
Based on tag:
- **Backend**: Check `src/Entity/`, `src/State/`, `src/Controller/`, `src/Security/`, `config/`
- **Frontend**: Check `frontend/components/`, `frontend/composables/`, `frontend/pages/`, `frontend/services/`
Conventions to follow:
- PHP: `declare(strict_types=1)`, Symfony + PSR-12, API Platform patterns
- Frontend: TypeScript strict, `useApi()` composable, 4 spaces indent
- See CLAUDE.md for full conventions
### 4. Verify
- For Backend: `make php-cs-fixer-allow-risky` if PHP changed
- For Frontend: check TypeScript types, no `any`
- Read modified files to confirm correctness
### 5. Set Status to "Terminé" (ID: 5)
- Use MCP `update-task` with `statusId: 5` after successful implementation
### 6. Log Learnings
Append to `.claude/skills/ticket-executor/LEARNINGS.md`:
- What worked well
- Patterns discovered
- Gotchas encountered
- Time-saving shortcuts found
## MCP Session Management
The MCP HTTP transport requires a session. To call tools:
```bash
# Initialize session (get Mcp-Session-Id from response header)
curl -si -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}'
# Call tool (use Mcp-Session-Id from init response)
curl -s -X POST http://project.malio-dev.fr/_mcp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update-task","arguments":{"id":<taskId>,"statusId":<statusId>}}}'
```
## Status IDs
- 1 = A faire
- 2 = En cours
- 3 = Bloqué
- 4 = En attente de validation
- 5 = Terminé
## Learnings Integration
Before each ticket, read `LEARNINGS.md` to apply previous insights.
After each ticket, append new learnings. This creates a feedback loop that improves execution quality over time.
## Parallel Execution Rules
- Independent tickets (no shared files) can run in parallel via worktree agents
- Tickets modifying the same files must run sequentially
- Always verify no merge conflicts after parallel execution

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
.git
.gitea
.env.local
.env.test
infra/dev/
infra/prod/docker-compose.yml
infra/prod/deploy.sh
infra/prod/deploy-release.sh
infra/prod/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
vendor/
LOG/
docs/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

18
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET="a64f5614357bf56aecb1d7470e431535" APP_SECRET="change_me_in_env_local"
APP_DEBUG=1 APP_DEBUG=1
DEFAULT_URI=http://localhost/ DEFAULT_URI=http://localhost/
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f JWT_PASSPHRASE=change_me_in_env_local
JWT_COOKIE_SECURE=0 JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400 JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400 JWT_COOKIE_TTL=86400
@@ -20,4 +20,16 @@ JWT_COOKIE_TTL=86400
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
ENCRYPTION_KEY=aaaaaaaaa ENCRYPTION_KEY=change_me_in_env_local
###> symfony/lock ###
# Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

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 infra/dev/.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 (infra/dev/.env.docker)
#
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
# pour les valeurs par defaut. Creez infra/dev/.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

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/lesstime:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }}
docker push gitea.malio.fr/malio-dev/lesstime:latest

View File

@@ -1,66 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/lesstime-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

14
.gitignore vendored
View File

@@ -22,3 +22,17 @@
###> lexik/jwt-authentication-bundle ### ###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem /config/jwt/*.pem
###< lexik/jwt-authentication-bundle ### ###< lexik/jwt-authentication-bundle ###
###> ide ###
.idea/
###< ide ###
###> docker local ###
infra/dev/.env.docker.local
###< docker local ###
###> local db dumps ###
*.sql.gz
*.sql.gz:Zone.Identifier
REVIEW.md
###< local db dumps ###

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": { "mcpServers": {
"lesstime": { "lesstime": {
"command": "docker", "type": "http",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"] "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

@@ -2,6 +2,8 @@
Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
> **WIP — Intégration Mail (branche `feat/mail-integration`)** : client mail OVH IMAP. Avant de toucher au mail, lire `docs/mail-integration.md` (section « Statut & reprise » = bugs déjà corrigés, points en suspens, commandes). Code : `src/Mail/`, `src/Service/MailSyncService.php`, `src/Controller/Mail/`, `frontend/{services,stores,components}/mail*`.
## Stack ## Stack
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
@@ -12,10 +14,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure ## Structure
``` ```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument) src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
src/ApiResource/ # Ressources API Platform (si découplées des entités) src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor) src/Enum/ # PHP enums (RecurrenceType)
src/Service/ # Services métier (NotificationService) 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/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/) src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP) src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
@@ -30,10 +33,10 @@ docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4 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/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/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/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer) frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, 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/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/) frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
``` ```
@@ -68,6 +71,13 @@ Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
### Tags & Versioning
- La version de l'app est dans `config/version.yaml` (paramètre `app.version`)
- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version
- Faire un commit séparé de bump : `chore : bump version to v<X.Y.Z>`
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
### Backend ### Backend
- Toujours `declare(strict_types=1)` en haut des fichiers PHP - Toujours `declare(strict_types=1)` en haut des fichiers PHP
@@ -95,9 +105,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal` - Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions - Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
### Composants UI
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
### MCP Server ### 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 STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>` - Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` - Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
@@ -117,7 +131,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Container PHP : `php-lesstime-fpm` - Container PHP : `php-lesstime-fpm`
- Container Nginx : `nginx-lesstime` - Container Nginx : `nginx-lesstime`
- Container DB : PostgreSQL sur port **5435** (interne et externe) - Container DB : PostgreSQL sur port **5435** (interne et externe)
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - Après modif nginx : `docker restart nginx-lesstime`
## Fixtures ## Fixtures
@@ -126,3 +140,14 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER) - 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) - 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` - 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)
## Delegation Codex
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.

0
LOG/xdebug.log Normal file
View File

View File

@@ -21,6 +21,7 @@ Application de gestion de projet avec suivi du temps et portail client.
- Profil utilisateur avec avatar (crop circulaire) - Profil utilisateur avec avatar (crop circulaire)
- Notifications temps réel - Notifications temps réel
- Intégration Gitea (issues, repos) - Intégration Gitea (issues, repos)
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
- Serveur MCP pour assistants IA - Serveur MCP pour assistants IA
- Multi-langue (i18n) - Multi-langue (i18n)
@@ -73,6 +74,7 @@ make shell-root # Shell root dans le container PHP
make dev-nuxt # Dev server Nuxt (hot reload, port 3002) make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
make cache-clear # Vider le cache Symfony make cache-clear # Vider le cache Symfony
make logs-dev # Tail logs Symfony make logs-dev # Tail logs Symfony
make mail-sync # Synchroniser la boîte mail IMAP (voir docs/mail-cron-setup.md)
``` ```
### Base de données ### Base de données
@@ -156,7 +158,7 @@ docker/ # Dockerfiles et config Nginx
| `nginx-lesstime` | 8082 | Nginx reverse proxy | | `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données | | PostgreSQL | 5435 | Base de données |
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`) Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
## API ## API

View File

@@ -16,24 +16,31 @@
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0", "phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
"symfony/console": "8.0.*", "symfony/console": "8.0.*",
"symfony/doctrine-messenger": "^8.0",
"symfony/dotenv": "8.0.*", "symfony/dotenv": "8.0.*",
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/lock": "8.0.*",
"symfony/mcp-bundle": "^0.6.0", "symfony/mcp-bundle": "^0.6.0",
"symfony/messenger": "^8.0",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*" "symfony/yaml": "8.0.*",
"webklex/php-imap": "^6.2"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@@ -91,7 +98,7 @@
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94", "friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0", "phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*", "symfony/browser-kit": "^8.0",
"symfony/css-selector": "8.0.*" "symfony/css-selector": "^8.0"
} }
} }

2575
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 Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle; use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [ return [
FrameworkBundle::class => ['all' => true], FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true], SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true], DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true], DoctrineMigrationsBundle::class => ['all' => true],
@@ -24,4 +23,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true], LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true], McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
]; ];

View File

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

View File

@@ -0,0 +1,2 @@
framework:
lock: '%env(LOCK_DSN)%'

View File

@@ -19,5 +19,8 @@ mcp:
path: /_mcp path: /_mcp
session: session:
store: file store: file
directory: '%kernel.cache_dir%/mcp-sessions' directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600 ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -0,0 +1,28 @@
framework:
messenger:
failure_transport: failed
transports:
sync: 'sync://'
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: default
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 0
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
routing:
'App\Message\MailSyncRequested': async
when@test:
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'

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 pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
login_throttling:
max_attempts: 5
interval: '1 minute'
json_login: json_login:
check_path: /login_check check_path: /login_check
username_path: username username_path: username
@@ -59,7 +62,10 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public # Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
# Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker)
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test: when@test:

View File

@@ -0,0 +1,5 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

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

View File

@@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* }, * },
* translator?: bool|array{ // Translator configuration * translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* fallbacks?: list<scalar|Param|null>, * fallbacks?: list<scalar|Param|null>,
* logging?: bool|Param, // Default: false * logging?: bool|Param, // Default: false
* formatter?: scalar|Param|null, // Default: "translator.formatter.default" * formatter?: scalar|Param|null, // Default: "translator.formatter.default"
@@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: true
* }, * },
* lock?: bool|string|array{ // Lock configuration * lock?: bool|string|array{ // Lock configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* resources?: array<string, string|list<scalar|Param|null>>, * resources?: array<string, string|list<scalar|Param|null>>,
* }, * },
* semaphore?: bool|string|array{ // Semaphore configuration * semaphore?: bool|string|array{ // Semaphore configuration
@@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* resources?: array<string, scalar|Param|null>, * resources?: array<string, scalar|Param|null>,
* }, * },
* messenger?: bool|array{ // Messenger configuration * messenger?: bool|array{ // Messenger configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* routing?: array<string, string|array{ // Default: [] * routing?: array<string, string|array{ // Default: []
* senders?: list<scalar|Param|null>, * senders?: list<scalar|Param|null>,
* }>, * }>,
@@ -624,7 +624,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* rate_limiter?: bool|array{ // Rate limiter configuration * rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: [] * limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
@@ -685,38 +685,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* }, * },
* } * }
* @psalm-type TwigConfig = array{
* form_themes?: list<scalar|Param|null>,
* globals?: array<string, array{ // Default: []
* id?: scalar|Param|null,
* type?: scalar|Param|null,
* value?: mixed,
* }>,
* autoescape_service?: scalar|Param|null, // Default: null
* autoescape_service_method?: scalar|Param|null, // Default: null
* cache?: scalar|Param|null, // Default: true
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
* debug?: bool|Param, // Default: "%kernel.debug%"
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
* auto_reload?: scalar|Param|null,
* optimizations?: int|Param,
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
* file_name_pattern?: list<scalar|Param|null>,
* paths?: array<string, mixed>,
* date?: array{ // The default format options used by the date filter.
* format?: scalar|Param|null, // Default: "F j, Y H:i"
* interval_format?: scalar|Param|null, // Default: "%d days"
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
* },
* number_format?: array{ // The default format options for the number_format filter.
* decimals?: int|Param, // Default: 0
* decimal_point?: scalar|Param|null, // Default: "."
* thousands_separator?: scalar|Param|null, // Default: ","
* },
* mailer?: array{
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
* },
* }
* @psalm-type SecurityConfig = array{ * @psalm-type SecurityConfig = array{
* access_denied_url?: scalar|Param|null, // Default: null * access_denied_url?: scalar|Param|null, // Default: null
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
@@ -1291,8 +1259,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false * handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false
* enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true * enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true
* enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false * enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false
* enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true * enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: false
* enable_re_doc?: bool|Param, // Enable ReDoc // Default: true * enable_re_doc?: bool|Param, // Enable ReDoc // Default: false
* enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true * enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true
* enable_docs?: bool|Param, // Enable the docs // Default: true * enable_docs?: bool|Param, // Enable the docs // Default: true
* enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true * enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true
@@ -1392,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false * include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
* }, * },
* messenger?: bool|array{ * messenger?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* }, * },
* elasticsearch?: bool|array{ * elasticsearch?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
@@ -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{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1654,12 +1764,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig, * mcp?: McpConfig,
* monolog?: MonologConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1667,13 +1777,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig, * mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1681,13 +1791,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig, * mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* twig?: TwigConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* doctrine?: DoctrineConfig, * doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig, * doctrine_migrations?: DoctrineMigrationsConfig,
@@ -1695,6 +1805,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig, * mcp?: McpConfig,
* monolog?: MonologConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.2.0' app.version: '0.4.1'

View File

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

364
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,364 @@
# Deploiement Docker — Lesstime
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer Lesstime.
Creer la base de donnees pour Lesstime :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE lesstime_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne Lesstime sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/lesstime
sudo chown -R $(whoami):$(whoami) /var/www/lesstime
cd /var/www/lesstime
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
container_name: lesstime-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export LESSTIME_IMAGE_TAG="$TAG"
echo "==> Deploying lesstime:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
sudo docker compose pull
echo "==> Starting container..."
sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
# App
DEFAULT_URI=https://project.malio-dev.fr
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/lesstime.conf /etc/nginx/sites-enabled/lesstime.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp lesstime.sql user@serveur:/tmp/lesstime.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio lesstime_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio lesstime_prod < /tmp/lesstime.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/lesstime
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/lesstime.sql
```
### Structure finale du dossier
```
/var/www/lesstime/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
├── public/
│ └── maintenance.html # extrait automatiquement par deploy.sh
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/lesstime
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.3.12
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.3.12
```
---
## CI/CD
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
1. Build l'image multi-stage
2. Push vers `gitea.malio.fr/malio-dev/lesstime:<tag>` et `:latest`
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
---
## Voir les logs
```bash
cd /var/www/lesstime
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (bare-metal)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/lesstime-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/lesstime/.env /var/www/lesstime-docker/.env
cp -a /var/www/lesstime/config/jwt /var/www/lesstime-docker/config/jwt
cp -a /var/www/lesstime/var/uploads /var/www/lesstime-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/lesstime-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/lesstime-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/lesstime-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/lesstime-docker /var/www/lesstime`

View File

@@ -0,0 +1,153 @@
# Configuration du mode maintenance (nginx hote)
Guide pour activer le support du mode maintenance pilote par Central.
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
## Ce qui a ete fait pour Lesstime
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/lesstime
sudo ./deploy.sh
```
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
```
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/public;
# Maintenance mode
if (-f /var/www/lesstime/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 4. Verifier
- Depuis Central, activer la maintenance sur Lesstime
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
- Desactiver la maintenance depuis Central → le site revient
---
## A faire pour Inventory
Meme procedure :
### 1. Deployer pour extraire la page maintenance
```bash
cd /var/www/inventory
sudo ./deploy.sh
```
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
> ```bash
> mkdir -p public
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
> ```
### 2. Mettre a jour la conf nginx de l'hote
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
```nginx
server {
listen 80;
listen [::]:80;
server_name inventory.malio-dev.fr;
root /var/www/inventory/public;
# Maintenance mode
if (-f /var/www/inventory/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 3. Recharger nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
---
## Fonctionnement
```
Central (container)
└── touch /var/www/maintenance/lesstime/maintenance.on
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
/var/www/lesstime/maintenance.on (hote)
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
maintenance.html servie depuis /var/www/lesstime/public/
```

View File

@@ -2,7 +2,7 @@ services:
php: php:
container_name: php-${DOCKER_APP_NAME}-fpm container_name: php-${DOCKER_APP_NAME}-fpm
build: build:
context: ./docker/php context: ./infra/dev
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION} DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -21,8 +21,8 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer - ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn - ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer - ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini - ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG - ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads - uploads_data:/var/www/html/var/uploads
extra_hosts: extra_hosts:
@@ -41,7 +41,7 @@ services:
- "8082:80" - "8082:80"
volumes: volumes:
- ./:/var/www/html:ro - ./:/var/www/html:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro - ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped restart: unless-stopped
db: db:
image: postgres:16-alpine image: postgres:16-alpine

View File

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

View File

@@ -0,0 +1,87 @@
# Règle Claude : Time Tracking automatique via Lesstime
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
---
## Time Tracking obligatoire
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
### Déclencheurs
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
### Méthode
Créer la time entry via **curl** sur l'API REST Lesstime :
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
- Body : `{"username":"admin","password":"admin"}`
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
- Body :
```json
{
"user": "/api/users/5",
"startedAt": "<ISO8601 avec timezone>",
"title": "<description courte de la tâche>",
"project": "/api/projects/<projectId>",
"tags": ["/api/task_tags/<tagId>"],
"task": "/api/tasks/<taskId>"
}
```
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
- Body : `{"stoppedAt": "<ISO8601>"}`
### Paramètres obligatoires
- **user** : TOUJOURS `/api/users/5` (Matthieu)
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
- **title** : description courte de la tâche en cours
- **project** : selon le projet (voir mapping ci-dessous)
### Tags (choisir selon le type de travail)
| Tag | ID | IRI |
|-----|----|-----|
| Backend | 3 | `/api/task_tags/3` |
| Frontend | 2 | `/api/task_tags/2` |
| IA | 7 | `/api/task_tags/7` |
| Infra | 5 | `/api/task_tags/5` |
| UI/UX | 4 | `/api/task_tags/4` |
| Maintenance | 6 | `/api/task_tags/6` |
| RDV | 1 | `/api/task_tags/1` |
| Réunion | 8 | `/api/task_tags/8` |
| Formation | 10 | `/api/task_tags/10` |
| Gestion projet | 9 | `/api/task_tags/9` |
### Mapping projets
| Projet | ID | IRI |
|--------|----|-----|
| Lesstime | 5 | `/api/projects/5` |
| Inventory | 7 | `/api/projects/7` |
| SIRH | 12 | `/api/projects/12` |
| Infrastructure | 13 | `/api/projects/13` |
| Malio UI | 11 | `/api/projects/11` |
| ERP Liot | 6 | `/api/projects/6` |
| Ferme | 8 | `/api/projects/8` |
| ADMIN | 16 | `/api/projects/16` |
| Maintenance-LIOT | 17 | `/api/projects/17` |
| Qualiopi | 14 | `/api/projects/14` |
| Vaultwarden | 18 | `/api/projects/18` |
### Règles
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
- **Toujours stopper le timer** en fin de tâche ou sur demande
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
- **Choisir les tags intelligemment** selon le type de travail effectué

View File

@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
## 4. Installer le script de deploy ## 4. Installer le script de deploy
```bash ```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime sudo chmod +x /usr/local/bin/deploy-lesstime
``` ```
@@ -74,7 +74,7 @@ sudo chmod 600 /etc/lesstime-release-token
## 5. Deployer une release ## 5. Deployer une release
```bash ```bash
sudo /usr/local/bin/deploy-lesstime v0.2.0 sudo /usr/local/bin/deploy-lesstime v0.2.1
``` ```
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations. Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
## 7. Configurer Nginx ## 7. Configurer Nginx
```bash ```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/ sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```

111
docs/mail-cron-setup.md Normal file
View File

@@ -0,0 +1,111 @@
# Mail Integration — Configuration cron OS
## Vue d'ensemble
La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP.
Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche
les runs de se chevaucher si une sync prend plus de 10 min.
## Prérequis
- Container `php-lesstime-fpm` démarré (`make start`)
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
## Installation du cron
Sur la **machine hôte** (pas dans le container) :
```bash
crontab -e
```
Ajouter la ligne suivante (adapter le chemin) :
```cron
*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
```
Ou directement via `docker exec` (sans dépendance à `make`) :
```cron
*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
```
### Avec un utilisateur système dédié
Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
```bash
sudo crontab -u deploy -e
```
## Variables d'environnement nécessaires
| Variable | Description | Exemple |
|---|---|---|
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) |
La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
## Checklist setup production
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
6. [ ] Installer le cron OS (voir section "Installation du cron")
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
## Commandes utiles
```bash
# Sync complète (toutes les boîtes)
make mail-sync
# Sync d'un seul dossier (le dossier doit déjà exister en base)
make mail-sync FOLDER=INBOX
# Simulation (dry-run, pas d'écriture BDD)
make mail-sync DRYRUN=1
# Directement dans le container
docker exec php-lesstime-fpm php bin/console app:mail:sync
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
```
## Logs
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
Suivre les logs en temps réel :
```bash
make logs-dev
```
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
## Sécurité
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`)
## Rappels sécurité
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base)
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
- Les pixels tracking distants sont remplacés par un placeholder
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
## Production
En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base).

147
docs/mail-integration.md Normal file
View File

@@ -0,0 +1,147 @@
# Intégration Mail — Vue d'ensemble
> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20)
>
> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/5 (base `develop`)
> Construit en 7 phases (plans dans `docs/superpowers/plans/2026-05-19-mail-phase*.md`).
>
> ### Ce qui marche (testé contre une vraie boîte OVH `contact@malio.fr`)
> - Connexion IMAP + test connexion (admin → `/admin` onglet Mail)
> - Synchro complète multi-dossiers : **456 messages / 57 dossiers** ramenés, ne crashe plus
> - Lecture dossiers/messages dans `/mail`, arbre repliable (chevrons, sous-dossiers masqués par défaut)
> - Lecture d'un mail, sanitization DOMPurify
> - Création/lien tâche depuis un mail
>
> ### Bugs déjà corrigés ce soir (NE PAS ré-investiguer)
> Tous dans `ImapMailProvider` / `MailSyncService` — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live :
> 1. Requête sans critère → `BAD parse error: zero-length content` → `whereAll()`
> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites
> 3. Séquence par défaut `ST_MSGN` → `peek()` faisait un STORE rejeté par OVH (`flag could not be removed`) → forcé `ST_UID` partout
> 4. Snippet via `getTextBody()` = fetch du corps de chaque mail (sync 179s + peek) → `setFetchBody(false)`, snippet désactivé au listing
> 5. Test connexion exigeait `enabled=true` → découplé via `getClient(requireEnabled:false)` + `testConnection()`
> 6. Contrainte UNIQUE globale sur `message_id` → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. **Migration `Version20260520061736`** : index simple. Garde anti-cascade dans `MailSyncService` (reset `ManagerRegistry`).
> 7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (`closeConnection()` sur l'interface) + reconnexion ciblée après dossier en erreur.
> - Contrat front/back réaligné dans `frontend/services/mail.ts` (route `/mail/folders/{path}/messages`, mapping `messages→items`, `fromAddress→fromEmail`, détail plat→imbriqué).
>
> ### Points en suspens / à savoir
> - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main.
> - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker.
> - **~7 dossiers/139** à encodage spécial (ex: `INBOX/RH/.../SÉBASTIEN` en UTF7-modifié) ou réponses vides sont skippés proprement et réessayés au cycle suivant. Edge case webklex non bloquant.
> - **Dépendance** : `webklex/php-imap ^6.2` tire des paquets Laravel (`illuminate/*` via `carbon ^3`) dans ce projet Symfony — fonctionnel mais à valider en review.
> - 6 PHPUnit Notices (mocks sans expectations) non bloquantes.
>
> ### Commandes utiles
> ```bash
> make mail-sync # synchro complète
> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
> make test # 33 tests
> ```
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
## Fonctionnalités
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
- Liste paginée des messages (infinite scroll, cursor-based)
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
- Lien mail ↔ tâche (bidirectionnel)
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
- Badge non-lus en temps réel dans la sidebar (polling 30s)
## Endpoints API
| Méthode | URL | Rôle | Description |
|---------|-----|------|-------------|
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
## Sécurité
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
- Pixels tracking distants (img src http) remplacés par placeholder
- Aucun body, password ou contenu de pièce jointe dans les logs
## Dépendances
### Backend
- `webklex/php-imap` : client IMAP PHP
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
- `libsodium` (ext PHP) : chiffrement du password IMAP
### Frontend
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
## Fichiers clés
### Backend
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
- `src/Entity/MailFolder.php` — dossier IMAP synced
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
- `src/State/Mail/` — providers/processors API Platform (configuration)
### Frontend
- `frontend/pages/mail.vue` — page principale 3 colonnes
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
- `frontend/services/mail.ts` — service API (toutes les méthodes)
- `frontend/services/dto/mail.ts` — types TypeScript
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
## Synchronisation cron
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
Résumé :
```bash
# Cron OS (toutes les 10 min)
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
# Commandes Makefile
make mail-sync # Sync complète
make mail-sync FOLDER=INBOX # Sync d'un dossier
make mail-sync DRYRUN=1 # Simulation sans écriture
```
## Configuration admin
1. Aller sur `/admin` → onglet "Mail"
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
3. Cliquer "Tester la connexion"
4. Activer la synchronisation → Enregistrer
5. Configurer le cron OS
## Variables d'environnement
| Variable | Description | Obligatoire |
|----------|-------------|-------------|
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,264 @@
# Mail Integration — Master Plan
> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle.
**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md`
**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail.
**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante.
---
## Cartographie des phases
```
Phase 1 (Backend foundations) ──┐
├─→ Phase 2 (IMAP provider + sync) ──┐
│ ├─→ Phase 3 (API backend) ──┐
│ │ │
└─→─────────────────────────────────────────────────────────────────┤
Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘
├─→ Phase 5 (UI principale 3 colonnes)
├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer)
└─→ Phase 7 (Admin config + sidebar + polish)
```
Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes.
---
## Phase 1 — Backend Foundations
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md`
**Scope** :
- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`)
- Entité `MailFolder`
- Entité `MailMessage`
- Entité `TaskMailLink` (avec unique constraint)
- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository`
- Migration Doctrine unique créant les 4 tables (raw SQL)
- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto`
- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl)
- Exception `App\Mail\Exception\MailProviderException`
- Tests unitaires repositories (au moins le pattern singleton)
**Critère d'acceptation** :
- `make migration-migrate` passe sans erreur
- `php bin/console doctrine:schema:validate` OK
- `make test` vert (au moins les tests créés)
- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée
**Dépendances** : aucune (point d'entrée).
---
## Phase 2 — IMAP Provider + Sync
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md`
**Scope** :
- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4)
- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface`
- Lecture config via `MailConfigurationRepository::findSingleton()`
- Déchiffrement password via `TokenEncryptor`
- `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment`
- Wrapping erreurs en `MailProviderException`
- `App\Service\MailSyncService`
- `syncAll(): MailSyncReport`
- `syncFolder(string $folderPath): MailSyncReport`
- `syncFolderStructure(): void`
- Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%)
- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors)
- Symfony Lock (`mail.sync`, TTL 10 min)
- Commande console `app:mail:sync` (avec option `--folder=...`)
- Documentation cron OS + cible Makefile `make mail-sync`
- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké
**Critère d'acceptation** :
- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config
- Tests `make test` verts
- `make mail-sync` documentée dans Makefile
**Dépendances** : Phase 1.
---
## Phase 3 — API Backend
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md`
**Scope** :
- API Platform ressources :
- `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider
- `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer)
- Custom controllers (priority: 1) :
- `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion
- `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD
- `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC`
- `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min
- `POST /api/mail/messages/{id}/read` (body `{ read: bool }`)
- `POST /api/mail/messages/{id}/flag`
- `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`)
- `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`)
- `DELETE /api/mail/messages/{id}/link-task/{taskId}`
- `GET /api/tasks/{id}/mails`
- `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline
- `POST /api/mail/sync` — async via Messenger
- Message + Handler Symfony Messenger `MailSyncRequested`
- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite
- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé)
**Critère d'acceptation** :
- Tous endpoints répondent corrects status/format
- Tests `make test` verts
- ROLE_CLIENT refusé sur 100% des endpoints mail
- Password jamais leak dans les réponses
**Dépendances** : Phase 1, Phase 2.
---
## Phase 4 — Frontend Services + Store
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md`
**Scope** :
- Install npm `dompurify` + types
- `frontend/services/dto/mail.ts` : tous les types TS
- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`)
- `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged`
- `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask`
- `triggerSync`
- `getConfiguration`, `updateConfiguration`, `testConfiguration`
- `downloadAttachment` (retourne Blob)
- Store Pinia `frontend/stores/useMailStore.ts`
- State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount`
- Actions correspondantes
- Polling `pollUnreadCount()` toutes les 30s (start/stop)
- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `<img src="http(s)://...">` distants)
**Critère d'acceptation** :
- `cd frontend && npx tsc --noEmit` OK
- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon
**Dépendances** : Phase 3 (les endpoints doivent exister).
---
## Phase 5 — UI principale (page /mail)
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md`
**Scope** :
- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive
- Composants `frontend/components/mail/` :
- `MailFolderTree.vue` — arbre récursif avec badges unread, sélection
- `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates
- `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler)
- `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing`
- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs
- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits
- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture)
- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt)
**Critère d'acceptation** :
- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN
- ROLE_CLIENT redirigé vers `/portal`
- Pas d'XSS via body mail (test manuel avec un mail contenant `<script>alert(1)</script>`)
- Pixels tracking distants remplacés par placeholder
**Dépendances** : Phase 4.
---
## Phase 6 — Intégration Tâches
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md`
**Scope** :
- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli :
- Titre = subject
- Description = body plain text
- Picker projet + groupe + priorité
- À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès
- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé)
- Onglet **"Mails"** sur `TaskDrawer.vue` :
- Nouvelle section affichée à côté Documents / Time tracking / etc.
- Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`)
- Item cliquable → `router.push('/mail?messageId=' + id)`
- Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail)
- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche
**Critère d'acceptation** :
- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer
- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet
**Dépendances** : Phase 5.
---
## Phase 7 — Admin Config + Sidebar + Polish
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md`
**Scope** :
- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) :
- Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle
- Bouton "Tester la connexion" → `POST /api/mail/configuration/test`
- Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`)
- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant)
- Lien sidebar dans le layout default :
- Icône `material-symbols:mail-outline`
- Label traduit
- Badge unread (count `useMailStore.globalUnreadCount`)
- Visible uniquement pour `ROLE_USER && !ROLE_CLIENT`
- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout
- Documentation finale :
- README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité)
- Makefile : `make mail-sync` documentée
- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester)
- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local)
**Critère d'acceptation** :
- Admin peut configurer la boîte, tester, activer
- Sidebar affiche badge unread temps réel (30s polling)
- Doc d'install à jour
- Aucun warning console front, aucun ERROR PHP dans `make logs-dev`
**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API).
---
## Conventions communes à toutes les phases
- **TDD** : test rouge → code → test vert → commit
- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier
- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit
- **Commits** : format `<type>(mail) : <message>` (espace avant `:`)
- **Branche** : `feat/mail-integration` (créée au début de Phase 1)
- **Pas de jamais logger** : bodies, password, attachments
- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante
---
## Cycle d'exécution
Pour chaque phase N :
1. **Spawn subagent rédacteur** (`feature-dev:code-architect`)
- Input : ce master plan + spec + scope phase N
- Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test)
2. **Spawn subagent codeur** (`ruflo-core:coder`)
- Input : plan détaillé phase N
- Output : code + tests + commits (TDD strict)
3. **Review humaine** : user valide ou demande corrections
4. **Phase suivante** uniquement si OK

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
# Mail Integration — Phase 7 : Admin Config + Sidebar + Polish
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finaliser l'intégration mail avec l'UI admin de configuration, le lien sidebar avec badge unread temps réel (polling 30s), et la documentation utilisateur/opérationnelle finale.
**Architecture:** Onglet `AdminMailTab.vue` calqué sur `AdminZimbraTab.vue` (form IMAP/SMTP/credentials, bouton test connexion). Lien sidebar dans `layouts/default.vue` (visible ROLE_USER+ROLE_ADMIN seulement, masqué ROLE_CLIENT pur). Polling start au login / stop au logout via layout. Documentation finale dans `docs/` + section README mail.
**Tech Stack:** Nuxt 4, Vue 3 Composition API, @malio/layer-ui, Pinia (useMailStore).
---
## Fichiers créés / modifiés
| Fichier | Action |
|---------|--------|
| `frontend/components/admin/AdminMailTab.vue` | **Créer** |
| `frontend/pages/admin.vue` | **Modifier** (ajout onglet mail) |
| `frontend/layouts/default.vue` | **Modifier** (lien sidebar + polling lifecycle) |
| `frontend/i18n/locales/fr.json` | **Modifier** (clés mail.admin.* + mail.sidebar.*) |
| `frontend/i18n/locales/en.json` | **Modifier si présent** |
| `docs/mail-cron-setup.md` | **Modifier** (enrichir checklist prod + sécurité) |
| `docs/mail-integration.md` | **Créer** (doc complète intégration) |
---
## Task 1 : Composant `AdminMailTab.vue`
**Fichier cible :** `frontend/components/admin/AdminMailTab.vue`
**Modèle de référence :** `frontend/components/admin/AdminZimbraTab.vue` — reproduire exactement le même pattern (reactive form, hasPassword, isSaving/isTesting, loadSettings onMounted, handleSave/handleTest).
**Service à utiliser :** `useMailService()` depuis `~/services/mail` — méthodes `getConfiguration`, `updateConfiguration`, `testConfiguration`.
**DTOs :** `MailConfigurationDto`, `MailConfigurationUpdateDto`, `MailTestConnectionResultDto` depuis `~/services/dto/mail`.
### Étapes
- [ ] Créer `frontend/components/admin/AdminMailTab.vue`
- [ ] Déclarer le reactive form avec tous les champs de `MailConfigurationDto` (sauf `hasPassword`, qui est en lecture seule) :
```
protocol: '' (lecture seule "imap" en MVP — champ disabled)
imapHost: ''
imapPort: 993 (default OVH)
imapEncryption: 'ssl' (default OVH)
smtpHost: ''
smtpPort: 465 (default OVH)
smtpEncryption: 'ssl' (default OVH)
username: ''
password: '' (write-only — jamais pré-rempli)
sentFolderPath: '' (ex: "Sent Messages" ou "INBOX.Sent")
enabled: false
```
- [ ] `hasPassword` : `ref<boolean>(false)` — alimenté par `getConfiguration().hasPassword`
- [ ] `isSaving` : `ref<boolean>(false)`, `isTesting` : `ref<boolean>(false)`
- [ ] `testResult` : `ref<boolean | null>(null)` — réinitialisé à null au handleSave
- [ ] `loadSettings()` :
```ts
async function loadSettings(): Promise<void> {
const config = await getConfiguration()
form.protocol = config.protocol ?? 'imap'
form.imapHost = config.imapHost ?? ''
form.imapPort = config.imapPort ?? 993
form.imapEncryption = config.imapEncryption ?? 'ssl'
form.smtpHost = config.smtpHost ?? ''
form.smtpPort = config.smtpPort ?? 465
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
form.username = config.username ?? ''
form.sentFolderPath = config.sentFolderPath ?? ''
form.enabled = config.enabled
hasPassword.value = config.hasPassword
// password jamais pré-rempli
}
```
- [ ] `handleSave()` : construit un `MailConfigurationUpdateDto` — inclure `password` uniquement si `form.password` est non-vide, sinon omettre le champ. Après save réussi : `hasPassword.value = result.hasPassword`, vider `form.password`, `testResult.value = null`
- [ ] `handleTest()` : appelle `testConfiguration()`, `testResult.value = result.ok`. Le champ `result.error` est affiché en sous-texte si `testResult.value === false`
- [ ] Template — sections IMAP et SMTP avec labels traduits :
- Titre `h2` : `$t('mail.admin.title')`
- Section IMAP (`fieldset` ou `div` avec titre `$t('mail.admin.imapSection')`) :
- `MalioInputText` pour `imapHost` + helper text `$t('mail.admin.ovhDefaultsHelp')` sous le champ (texte gris : `ssl0.ovh.net`)
- `input[type=number]` natif pour `imapPort` (MalioInputText n'accepte pas les number — voir convention CLAUDE.md)
- `select` natif pour `imapEncryption` (options : `ssl`, `tls`, `none`)
- Section SMTP (`$t('mail.admin.smtpSection')`) :
- `MalioInputText` pour `smtpHost`
- `input[type=number]` natif pour `smtpPort`
- `select` natif pour `smtpEncryption` (options : `ssl`, `tls`, `none`)
- Credentials :
- `MalioInputText` pour `username`
- `MalioInputPassword` pour `password` + indicateur `hasPassword` (même pattern que `AdminZimbraTab.vue` : `<p v-if="hasPassword && !form.password">{{ $t('mail.admin.passwordSet') }}</p>`)
- `MalioInputText` pour `sentFolderPath` (placeholder: `Sent Messages`)
- `label` + checkbox natif pour `enabled` : `$t('mail.admin.enabled')`
- Boutons côte à côte :
- `MalioButton` submit `$t('mail.admin.save')` `:disabled="isSaving"` → `handleSave`
- `MalioButton` variant tertiary `$t('mail.admin.test')` `:disabled="isTesting"` → `handleTest`
- Résultat test : `<p v-if="testResult !== null">` coloré vert/rouge selon valeur — si false ET `testError`, afficher `testError` sous le résultat
- [ ] `onMounted(() => { loadSettings() })`
- [ ] Vérifier indentation 4 espaces, pas d'imports inutilisés, TypeScript strict
---
## Task 2 : Intégration `AdminMailTab` dans `pages/admin.vue`
**Fichier cible :** `frontend/pages/admin.vue`
Le pattern actuel utilise un tableau `tabs as const` + `activeTab` ref + v-if par composant. Il suffit d'ajouter l'entrée mail à la fin.
### Étapes
- [ ] Ouvrir `frontend/pages/admin.vue`
- [ ] Dans le tableau `tabs`, ajouter à la fin :
```ts
{ key: 'mail', label: 'Mail' },
```
Remarque : les labels dans `tabs` sont des string litéraux inline (cf. autres onglets comme `'Zimbra'`), pas de i18n ici.
- [ ] Le type `TabKey` est inféré automatiquement via `typeof tabs[number]['key']` — pas de changement nécessaire
- [ ] Dans le template, après `<AdminZimbraTab v-if="activeTab === 'zimbra'" />`, ajouter :
```html
<AdminMailTab v-if="activeTab === 'mail'" />
```
- [ ] Vérifier que Nuxt auto-importe `AdminMailTab` (fichier dans `components/admin/` → auto-import OK)
- [ ] Test manuel : naviguer vers `/admin`, cliquer l'onglet "Mail", vérifier que le form se charge sans erreur 403 si connecté ROLE_ADMIN
---
## Task 3 : Lien sidebar dans `layouts/default.vue`
**Fichier cible :** `frontend/layouts/default.vue`
Le composant `SidebarLink` accepte `to`, `icon`, `label`, `collapsed`. Il n'a pas de prop `badge` native — vérifier dans `@malio/layer-ui/COMPONENTS.md` si une prop badge existe. Si non, wrapper manuel avec un `<div class="relative">` + badge absolu.
### Étapes
- [ ] Lire `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour vérifier les props de `SidebarLink` (présence prop `badge` ou `badgeCount`)
- [ ] **Cas A — SidebarLink a une prop badge :**
Utiliser directement :
```html
<SidebarLink
v-if="isMailVisible"
to="/mail"
icon="material-symbols:mail-outline"
label="$t('mail.sidebar.title')"
:collapsed="sidebarIsCollapsed"
:badge="mailStore.globalUnreadCount > 0 ? mailStore.globalUnreadCount : undefined"
aria-label="$t('mail.sidebar.ariaLabel')"
@click="ui.closeMobileSidebar()"
/>
```
- [ ] **Cas B — SidebarLink n'a pas de prop badge (plus probable) :**
Wrapper avec badge manuel :
```html
<div v-if="isMailVisible" class="relative">
<SidebarLink
to="/mail"
icon="material-symbols:mail-outline"
:label="$t('mail.sidebar.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<span
v-if="mailStore.globalUnreadCount > 0"
class="absolute right-2 top-1/2 -translate-y-1/2 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
>
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span>
</div>
```
- [ ] Dans `<script setup>`, ajouter :
```ts
const mailStore = useMailStore()
```
- [ ] Définir le computed `isMailVisible` :
```ts
const isMailVisible = computed(() => {
const roles: string[] = auth.user?.roles ?? []
// Visible si ROLE_USER (ou ROLE_ADMIN) mais pas ROLE_CLIENT exclusif
const isClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_ADMIN') && !roles.includes('ROLE_USER')
return !isClient && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
})
```
- [ ] Placer le lien sidebar **après** `SidebarLink to="/my-tasks"` et **avant** `SidebarLink to="/projects"` (ordre logique : dashboard → mes tâches → mail → projets → suivi de temps → admin)
- [ ] Vérifier responsive : en mode collapsed (`sidebarIsCollapsed = true`), le badge doit rester visible et accessible
- [ ] Test manuel : utilisateur ROLE_CLIENT seul → lien absent. Utilisateur ROLE_USER → lien visible. Badge rouge si `globalUnreadCount > 0`
---
## Task 4 : Lifecycle polling start/stop
**Fichier cible :** `frontend/layouts/default.vue`
Le store `useMailStore` expose `startPolling()` (idempotent — guard `if (pollTimer) return`) et `stopPolling()`. Le polling doit démarrer au montage du layout (si l'utilisateur est autorisé) et s'arrêter au logout.
### Étapes
- [ ] Dans `onMounted` de `layouts/default.vue` (qui contient déjà `timerStore.fetchActive()`), ajouter après :
```ts
if (isMailVisible.value) {
mailStore.startPolling()
}
```
- [ ] Vérifier que `isMailVisible` est disponible dans le même scope (oui, c'est un computed défini dans `<script setup>`)
- [ ] Pour le stop au logout : dans `useAuthStore`, le logout vide l'user. Watcher sur `auth.user` dans le layout :
```ts
watch(() => auth.user, (user) => {
if (!user) {
mailStore.stopPolling()
} else if (isMailVisible.value) {
mailStore.startPolling()
}
})
```
- [ ] Vérifier l'idempotence : `startPolling()` dans le store a déjà `if (pollTimer) return` — naviguer entre les pages ne crée pas plusieurs timers
- [ ] `onUnmounted` dans le layout n'est pas nécessaire car le layout persiste toute la session ; le watch sur `auth.user` suffit
- [ ] Test manuel : ouvrir devtools → Network → vérifier un seul appel `GET /api/mail/folders` toutes les 30s, pas de rafale
---
## Task 5 : i18n additionnels Phase 7
**Fichiers cibles :** `frontend/i18n/locales/fr.json` (et `en.json` si présent)
### Clés à ajouter (section `mail` — fusionner avec les clés existantes des phases précédentes)
```json
{
"mail": {
"sidebar": {
"title": "Messagerie",
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
},
"admin": {
"title": "Configuration messagerie",
"protocol": "Protocole",
"imapSection": "Réception (IMAP)",
"smtpSection": "Envoi (SMTP)",
"host": "Serveur",
"port": "Port",
"encryption": "Chiffrement",
"username": "Adresse e-mail",
"password": "Mot de passe",
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
"sentFolderPath": "Dossier des envois",
"enabled": "Activer la synchronisation mail",
"test": "Tester la connexion",
"testSuccess": "Connexion IMAP réussie",
"testFailed": "Échec de connexion",
"save": "Enregistrer",
"saveSuccess": "Configuration enregistrée",
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
}
}
}
```
### Étapes
- [ ] Ouvrir `frontend/i18n/locales/fr.json`
- [ ] Localiser la section `mail` existante (créée en Phase 4/5)
- [ ] Fusionner les clés `mail.sidebar.*` et `mail.admin.*` sans écraser les clés existantes
- [ ] Si `en.json` existe : ajouter les équivalents anglais (traduction directe — pas d'approximation)
- [ ] Vérifier la cohérence JSON (virgules, pas de clés dupliquées)
- [ ] `make dev-nuxt` → console browser → 0 warning `[vue-i18n] Missing locale message`
---
## Task 6 : Documentation finale
### 6a — Enrichir `docs/mail-cron-setup.md`
**Fichier cible :** `docs/mail-cron-setup.md`
Ce fichier existe déjà (créé Phase 2). Ajouter les sections manquantes :
- [ ] Ajouter section **"Checklist setup production"** après la section "Variables d'environnement" :
```markdown
## Checklist setup production
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
6. [ ] Installer le cron OS (voir section "Installation du cron")
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
```
- [ ] Ajouter section **"Sécurité"** (si absente ou incomplète) :
```markdown
## Rappels sécurité
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base)
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
- Les pixels tracking distants sont remplacés par un placeholder
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
```
### 6b — Créer `docs/mail-integration.md`
**Fichier cible :** `docs/mail-integration.md`
- [ ] Créer le fichier avec les sections suivantes :
```markdown
# Intégration Mail — Vue d'ensemble
## Fonctionnalités
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
- Liste paginée des messages (infinite scroll, cursor-based)
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
- Lien mail ↔ tâche (bidirectionnel)
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
- Badge non-lus en temps réel dans la sidebar (polling 30s)
## Endpoints API
| Méthode | URL | Rôle | Description |
|---------|-----|------|-------------|
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
## Sécurité
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
- Pixels tracking distants (img src http) remplacés par placeholder
- Aucun body, password ou contenu de pièce jointe dans les logs
## Dépendances
### Backend
- `webklex/php-imap` : client IMAP PHP
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
- `libsodium` (ext PHP) : chiffrement du password IMAP
### Frontend
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
## Fichiers clés
### Backend
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
- `src/Entity/MailFolder.php` — dossier IMAP synced
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
- `src/State/Mail/` — providers/processors API Platform (configuration)
### Frontend
- `frontend/pages/mail.vue` — page principale 3 colonnes
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
- `frontend/services/mail.ts` — service API (toutes les méthodes)
- `frontend/services/dto/mail.ts` — types TypeScript
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
## Synchronisation cron
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
Résumé :
```bash
# Cron OS (toutes les 10 min)
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
# Commandes Makefile
make mail-sync # Sync complète
make mail-sync FOLDER=INBOX # Sync d'un dossier
make mail-sync DRYRUN=1 # Simulation sans écriture
```
## Configuration admin
1. Aller sur `/admin` → onglet "Mail"
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
3. Cliquer "Tester la connexion"
4. Activer la synchronisation → Enregistrer
5. Configurer le cron OS
## Variables d'environnement
| Variable | Description | Obligatoire |
|----------|-------------|-------------|
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
```
### 6c — Vérifier `make mail-sync` dans le README
- [ ] Ouvrir `README.md` à la racine de Lesstime
- [ ] Vérifier si une section mail ou une mention de `make mail-sync` existe déjà
- [ ] Si absente : ajouter dans la section des commandes Makefile une ligne documentant `make mail-sync` avec la description courte (cf. le commentaire déjà présent dans le makefile)
---
## Task 7 : Vérifications sécurité finales
### Étapes
- [ ] Ouvrir `frontend/utils/sanitizeMailHtml.ts` — vérifier la config DOMPurify :
- `FORBID_TAGS` doit inclure : `script`, `iframe`, `object`, `embed`, `form`, `input`
- `FORBID_ATTR` doit inclure tous les handlers `on*` + `javascript:` dans `href`/`src`
- Les `<img src="http(s)://...">` distants sont remplacés par un placeholder (pas juste supprimés)
- Si manquant, noter la correction mais ne pas modifier (la correction est documentée ici pour le codeur)
- [ ] Test injection XSS manuel (dans la console browser, sur la page `/mail`) :
```js
import('/utils/sanitizeMailHtml').then(m => {
console.log(m.sanitizeMailHtml('<script>alert(1)</script><img src=x onerror=alert(2)><iframe src="javascript:alert(3)"></iframe>'))
})
```
Résultat attendu : chaîne sans `<script>`, sans `onerror`, sans `<iframe>`
- [ ] Grep logs — confirmer aucun body/password/attachment dans les logs :
```bash
grep -rn "bodyHtml\|bodyText\|password\|attachment.*content" src/Mail/ src/Service/MailSyncService.php src/Controller/Mail/ --include="*.php"
```
Vérifier que les occurrences trouvées sont uniquement des définitions de propriétés, jamais passées à un logger
- [ ] Vérifier que `GET /api/mail/configuration` ne retourne jamais de champ `password` dans la réponse JSON (tester avec `curl -s http://localhost:8082/api/mail/configuration -H "Cookie: BEARER=..."` ou équivalent)
- [ ] Vérifier que `POST /api/mail/folders` avec un cookie ROLE_CLIENT retourne bien 403
---
## Task 8 : QA passe end-to-end
### Étapes
- [ ] `make test` → 0 failure, 0 error
- [ ] `make php-cs-fixer-allow-risky` → idempotent (0 fichier modifié)
- [ ] `cd frontend && npx tsc --noEmit` → 0 erreur TypeScript
- [ ] `make dev-nuxt` → démarrage OK, 0 erreur console browser au load de `/mail`
- [ ] **Workflow admin :**
- Se connecter en admin
- Aller sur `/admin` → onglet "Mail"
- Renseigner `imapHost = ssl0.ovh.net`, `imapPort = 993`, `imapEncryption = ssl`, `username = test@example.com`, `password = test`
- Cliquer "Tester la connexion" → résultat affiché (succès ou échec selon config réelle)
- Enregistrer → toast "Configuration enregistrée"
- Rechargement de la page → les champs sont pré-remplis, indicateur "Mot de passe déjà configuré" visible
- [ ] **Workflow sidebar :**
- Se connecter en ROLE_USER
- Vérifier que le lien "Messagerie" est visible dans la sidebar
- Vérifier le badge si `globalUnreadCount > 0`
- Se connecter en ROLE_CLIENT → vérifier l'absence du lien sidebar
- [ ] **Workflow polling :**
- Ouvrir les DevTools → Network → filtrer sur `mail/folders`
- Rester sur une page 90s → exactement 3 appels (1 immédiat + 2 toutes les 30s)
- Naviguer entre `/mail` et `/my-tasks` → pas de rafale, pas de duplication du polling
- [ ] **Workflow complet mail → tâche (régression Phase 6) :**
- Ouvrir un mail dans `/mail`
- Cliquer "Créer tâche" → modal → sélectionner projet → créer
- Tâche apparaît dans `/my-tasks` avec le mail lié
- Depuis le TaskDrawer de la tâche → onglet "Mails" → mail visible → cliquer → redirection `/mail?messageId=X`
- [ ] **Simulation sync :**
- `make mail-sync DRYRUN=1` → commande retourne 0, pas d'erreur Symfony
---
## Task 9 : Cleanup final
### Étapes
- [ ] Grep debug dans tous les fichiers mail frontend :
```bash
grep -rn "console\.log\|console\.warn\|console\.error\|debugger" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts frontend/utils/sanitizeMailHtml.ts
```
Supprimer toute occurrence (sauf `console.error` intentionnel avec commentaire explicatif)
- [ ] Grep TODO/FIXME/HACK :
```bash
grep -rn "TODO\|FIXME\|HACK\|XXX" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts
```
Résoudre ou supprimer chaque occurrence
- [ ] Vérifier qu'aucun import inutilisé ne traîne dans `AdminMailTab.vue` et les fichiers modifiés dans `layouts/default.vue`
- [ ] `cd frontend && npx tsc --noEmit` → toujours 0 erreur après cleanup
- [ ] Si des modifications ont été faites depuis le dernier commit Phase 6, créer un commit final :
```
feat(mail) : Phase 7 — admin config tab, sidebar badge, polling lifecycle
docs(mail) : documentation intégration mail complète
```
(deux commits séparés si les changements sont distincts)
---
## Critères d'acceptation (Phase 7 complète)
- [ ] Admin peut accéder à `/admin` → onglet "Mail" → configurer IMAP/SMTP → tester → activer
- [ ] Le sidebar affiche un badge unread actualisé toutes les 30s pour ROLE_USER/ROLE_ADMIN
- [ ] Le sidebar "Messagerie" est invisible pour ROLE_CLIENT exclusif
- [ ] `make test` vert
- [ ] `npx tsc --noEmit` 0 erreur
- [ ] 0 warning console browser au chargement
- [ ] 0 ERROR PHP dans `make logs-dev` pendant le workflow normal
- [ ] `docs/mail-integration.md` complet et accessible
- [ ] `docs/mail-cron-setup.md` enrichi avec checklist prod et rappels sécurité
---
## Dépendances
- **Phase 5** (store `useMailStore` avec `startPolling`/`stopPolling` + page `/mail`) — DONE
- **Phase 6** (intégration tâches) — DONE
- **Phase 3** (endpoints `/api/mail/configuration` GET/PATCH/test, ROLE_CLIENT refusé) — DONE
- **Phase 4** (services `getConfiguration`, `updateConfiguration`, `testConfiguration`, DTOs) — DONE

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

View File

@@ -0,0 +1,224 @@
# Workflows de statuts par projet (Kanban custom)
**Date** : 2026-05-19
**Branche** : `feat/project-workflows`
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
## Reprise sur un autre poste
> **Pour le prochain Claude qui ouvre cette branche :**
>
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
> 3. **Aucun code applicatif n'a encore été écrit.**
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
> 5. **Validations Matthieu (2026-05-19)** :
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
> - Suppression d'`AdminStatusTab` → **OK**.
> - Ordre des étapes de livraison (§10) → **OK**.
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
## 1. Contexte et besoin
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
## 2. Modèle de données
### Nouvelle entité : `Workflow`
```
Workflow
- id int, PK
- name string(255), unique
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
- position int (pour l'ordre dans l'admin)
- statuses OneToMany → TaskStatus (inverse côté Workflow)
```
### Modifications : `TaskStatus`
```
TaskStatus
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
```
### Modifications : `Project`
```
Project
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
```
### Choix de design
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
## 3. Migrations BDD
Trois migrations Doctrine successives :
**M1 — `create_workflow_table`**
- Crée la table `workflow` (id, name, is_default, position)
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
**M2 — `add_workflow_to_task_status`**
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
- "À faire" → `todo`
- "En cours" → `in_progress`
- "Bloqué" → `blocked`
- "En attente de validation" → `review`
- "Terminé" → `done`
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
- Passe les 2 colonnes en `NOT NULL`
**M3 — `add_workflow_to_project`**
- Ajoute `project.workflow_id` nullable
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
## 4. Backend (Symfony / API Platform)
### Entités
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
### Sérialisation
- Groupe `workflow:read` pour l'API admin
- `task_status:read` ajoute `workflow` et `category`
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
### Endpoint dédié au switch
```
POST /api/projects/{id}/switch-workflow
Body: {
workflowId: int,
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
}
Security: ROLE_ADMIN
```
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
3. Transaction unique :
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
- `UPDATE project SET workflow_id = <new>`
4. Retourne `{ project, migratedTaskCount }`
### Validation cross-entity
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
### Suppression d'un Workflow
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
## 5. Frontend (Nuxt / Vue)
### Nouveaux fichiers
- `frontend/services/workflows.ts` — service API CRUD
- `frontend/services/dto/workflow.ts` — type TS
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
### Modifications
- `frontend/components/admin/AdminStatusTab.vue` :
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
- `frontend/components/project/ProjectDrawer.vue` :
- Affiche le workflow actuel
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
- `frontend/pages/projects/[id]/index.vue` :
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
- Le kanban a les colonnes du workflow du projet
- `frontend/pages/projects/[id]/archives.vue` :
- Filtre statut limité au workflow du projet
- `frontend/pages/my-tasks.vue` :
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
- Chaque card affiche le statut spécifique en badge
- Vue liste : pas de changement
- `frontend/components/task/TaskModal.vue` :
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
- `frontend/components/task/TaskBulkActions.vue` :
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
### `ProjectWorkflowSwitchModal.vue` — détails UX
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
- Étape 2 (après sélection) : tableau de mapping
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
- Footer :
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
## 6. MCP
| Tool | Changement |
|---|---|
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
## 7. Permissions
| Action | Rôle requis |
|---|---|
| Lire les workflows et leurs statuts | `ROLE_USER` |
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
## 8. Hors scope (YAGNI explicites)
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
- **Versioning des workflows** (historique des modifs) — pas demandé
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
## 9. Risques et limites
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
## 10. Étapes de livraison suggérées
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
4. Service frontend `workflows` + types DTO
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
9. Adaptation `TaskBulkActions` et autres écrans transverses
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.

View File

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

View File

@@ -10,21 +10,17 @@
input-class="w-full" input-class="w-full"
/> />
<MalioInputText <MalioInputPassword
v-model="form.tokenId" v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')" :label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full" input-class="w-full"
type="password"
/> />
<div> <div>
<MalioInputText <MalioInputPassword
v-model="form.tokenSecret" v-model="form.tokenSecret"
:label="$t('bookstack.settings.tokenSecret')" :label="$t('bookstack.settings.tokenSecret')"
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
input-class="w-full" input-class="w-full"
type="password"
/> />
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600"> <p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
{{ $t('bookstack.settings.tokenConfigured') }} {{ $t('bookstack.settings.tokenConfigured') }}
@@ -32,21 +28,19 @@
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button <MalioButton
type="submit" :label="$t('bookstack.settings.save')"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50" button-class="w-auto px-4"
:disabled="isSaving" :disabled="isSaving"
> @click="handleSave"
{{ $t('bookstack.settings.save') }} />
</button> <MalioButton
<button variant="tertiary"
type="button" :label="$t('bookstack.settings.testConnection')"
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" button-class="w-auto px-4"
:disabled="isTesting" :disabled="isTesting"
@click="handleTest" @click="handleTest"
> />
{{ $t('bookstack.settings.testConnection') }}
</button>
</div> </div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'"> <p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -2,12 +2,13 @@
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Clients</h2> <h2 class="text-lg font-bold text-neutral-900">Clients</h2>
<button <MalioButton
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un client"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un client
</button>
</div> </div>
<DataTable <DataTable

View File

@@ -92,19 +92,21 @@
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td> <td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3"> <td class="px-3 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <MalioButtonIcon
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500" icon="mdi:swap-horizontal"
:title="$t('clientTicket.changeStatus')" :aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="18"
@click.stop="openStatusChange(ticket)" @click.stop="openStatusChange(ticket)"
> />
<Icon name="mdi:swap-horizontal" size="18" /> <MalioButtonIcon
</button> icon="mdi:delete-outline"
<button aria-label="Supprimer"
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500" variant="ghost"
icon-size="18"
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
@click.stop="openDeleteConfirm(ticket)" @click.stop="openDeleteConfirm(ticket)"
> />
<Icon name="mdi:delete-outline" size="18" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -155,19 +157,18 @@
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button <MalioButton
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50" variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false" @click="statusModalOpen = false"
> />
{{ $t('common.cancel') }} <MalioButton
</button> label="Confirmer"
<button button-class="w-auto px-6"
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" :disabled="isUpdatingStatus"
@click="confirmStatusChange" @click="confirmStatusChange"
> />
Confirmer
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -186,19 +187,19 @@
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p> <p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button <MalioButton
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50" variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="deleteModalOpen = false" @click="deleteModalOpen = false"
> />
{{ $t('common.cancel') }} <MalioButton
</button> variant="danger"
<button label="Supprimer"
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50" button-class="w-auto px-6"
:disabled="isDeleting" :disabled="isDeleting"
@click="confirmDelete" @click="confirmDelete"
> />
Supprimer
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -274,25 +275,22 @@ const availableStatusTransitions = computed(() => {
}) })
function getProjectName(iri: string): string { function getProjectName(iri: string): string {
const match = iri.match(/\/api\/projects\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return '' if (!id) return ''
const id = Number(match[1])
return projects.value.find(p => p.id === id)?.name ?? '' return projects.value.find(p => p.id === id)?.name ?? ''
} }
function getSubmitterName(iri: string | null): string { function getSubmitterName(iri: string | null): string {
if (!iri) return '-' if (!iri) return '-'
const match = iri.match(/\/api\/users\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return '' if (!id) return ''
const id = Number(match[1])
return users.value.find(u => u.id === id)?.username ?? '' return users.value.find(u => u.id === id)?.username ?? ''
} }
function getSubmitterUser(iri: string | null): UserData | undefined { function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/) const id = extractIdFromIri(iri)
if (!match) return undefined if (!id) return undefined
const id = Number(match[1])
return users.value.find(u => u.id === id) return users.value.find(u => u.id === id)
} }

View File

@@ -2,12 +2,13 @@
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2> <h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
<button <MalioButton
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un effort"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un effort
</button>
</div> </div>
<DataTable <DataTable

View File

@@ -11,12 +11,10 @@
/> />
<div> <div>
<MalioInputText <MalioInputPassword
v-model="form.token" v-model="form.token"
:label="$t('gitea.settings.token')" :label="$t('gitea.settings.token')"
:placeholder="$t('gitea.settings.tokenPlaceholder')"
input-class="w-full" input-class="w-full"
type="password"
/> />
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600"> <p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
{{ $t('gitea.settings.tokenConfigured') }} {{ $t('gitea.settings.tokenConfigured') }}
@@ -24,21 +22,19 @@
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button <MalioButton
type="submit" :label="$t('gitea.settings.save')"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50" button-class="w-auto px-4"
:disabled="isSaving" :disabled="isSaving"
> @click="handleSave"
{{ $t('gitea.settings.save') }} />
</button> <MalioButton
<button variant="tertiary"
type="button" :label="$t('gitea.settings.testConnection')"
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" button-class="w-auto px-4"
:disabled="isTesting" :disabled="isTesting"
@click="handleTest" @click="handleTest"
> />
{{ $t('gitea.settings.testConnection') }}
</button>
</div> </div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'"> <p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">

View File

@@ -0,0 +1,231 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('mail.admin.title') }}</h2>
<form class="mt-6 max-w-lg space-y-6" @submit.prevent="handleSave">
<!-- Section IMAP (réception) -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.imapSection') }}</legend>
<div>
<MalioInputText
v-model="form.imapHost"
:label="$t('mail.admin.host')"
input-class="w-full"
/>
<p class="mt-1 text-xs text-neutral-500">{{ $t('mail.admin.ovhDefaultsHelp') }}</p>
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
<input
v-model.number="form.imapPort"
type="number"
min="1"
max="65535"
class="mt-1 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="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
<select
v-model="form.imapEncryption"
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
<option value="none">Aucun</option>
</select>
</div>
</fieldset>
<!-- Section SMTP (envoi) -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.smtpSection') }}</legend>
<MalioInputText
v-model="form.smtpHost"
:label="$t('mail.admin.host')"
input-class="w-full"
/>
<div>
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
<input
v-model.number="form.smtpPort"
type="number"
min="1"
max="65535"
class="mt-1 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="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
<select
v-model="form.smtpEncryption"
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
<option value="none">Aucun</option>
</select>
</div>
</fieldset>
<!-- Credentials -->
<fieldset class="space-y-4">
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.username') }}</legend>
<MalioInputText
v-model="form.username"
:label="$t('mail.admin.username')"
input-class="w-full"
/>
<div>
<MalioInputPassword
v-model="form.password"
:label="$t('mail.admin.password')"
input-class="w-full"
/>
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
{{ $t('mail.admin.passwordSet') }}
</p>
</div>
<MalioInputText
v-model="form.sentFolderPath"
:label="$t('mail.admin.sentFolderPath')"
placeholder="Sent Messages"
input-class="w-full"
/>
</fieldset>
<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('mail.admin.enabled') }}</span>
</label>
<div class="flex gap-3">
<MalioButton
:label="$t('mail.admin.save')"
button-class="w-auto px-4"
:disabled="isSaving"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('mail.admin.test')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
/>
</div>
<div v-if="testResult !== null">
<p
class="text-sm font-medium"
:class="testResult ? 'text-green-600' : 'text-red-600'"
>
{{ testResult ? $t('mail.admin.testSuccess') : $t('mail.admin.testFailed') }}
</p>
<p v-if="testResult === false && testError" class="mt-1 text-xs text-neutral-500">
{{ testError }}
</p>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { useMailService } from '~/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
const form = reactive({
protocol: 'imap',
imapHost: '',
imapPort: 993,
imapEncryption: 'ssl',
smtpHost: '',
smtpPort: 465,
smtpEncryption: 'ssl',
username: '',
password: '',
sentFolderPath: '',
enabled: false,
})
const hasPassword = ref<boolean>(false)
const isSaving = ref<boolean>(false)
const isTesting = ref<boolean>(false)
const testResult = ref<boolean | null>(null)
const testError = ref<string | null>(null)
async function loadSettings(): Promise<void> {
const config = await getConfiguration()
form.protocol = config.protocol ?? 'imap'
form.imapHost = config.imapHost ?? ''
form.imapPort = config.imapPort ?? 993
form.imapEncryption = config.imapEncryption ?? 'ssl'
form.smtpHost = config.smtpHost ?? ''
form.smtpPort = config.smtpPort ?? 465
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
form.username = config.username ?? ''
form.sentFolderPath = config.sentFolderPath ?? ''
form.enabled = config.enabled
hasPassword.value = config.hasPassword
// password jamais pré-rempli
}
async function handleSave(): Promise<void> {
isSaving.value = true
testResult.value = null
testError.value = null
try {
const payload: Record<string, unknown> = {
protocol: form.protocol,
imapHost: form.imapHost.trim() || null,
imapPort: form.imapPort,
imapEncryption: form.imapEncryption,
smtpHost: form.smtpHost.trim() || null,
smtpPort: form.smtpPort,
smtpEncryption: form.smtpEncryption,
username: form.username.trim() || null,
sentFolderPath: form.sentFolderPath.trim() || null,
enabled: form.enabled,
}
if (form.password) {
payload.password = form.password
}
const result = await updateConfiguration(payload)
hasPassword.value = result.hasPassword
form.password = ''
} finally {
isSaving.value = false
}
}
async function handleTest(): Promise<void> {
isTesting.value = true
testResult.value = null
testError.value = null
try {
const result = await testConfiguration()
testResult.value = result.ok
if (!result.ok && result.error) {
testError.value = result.error
}
} catch {
testResult.value = false
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>

View File

@@ -2,12 +2,13 @@
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2> <h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
<button <MalioButton
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter une priorité"
@click="openCreate" @click="openCreate"
> />
+ Ajouter une priorité
</button>
</div> </div>
<DataTable <DataTable

View File

@@ -1,139 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un statut
</button>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun statut trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-color="{ item }">
<span
class="inline-block h-6 w-6 rounded-full"
:style="{ backgroundColor: item.color }"
/>
</template>
</DataTable>
<TaskStatusDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
<ConfirmDeleteStatusModal
v-model="confirmModalOpen"
:status-label="statusToDelete?.label ?? ''"
:task-count="affectedTaskCount"
:available-statuses="reassignTargets"
@confirm="onConfirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import type { Task } from '~/services/dto/task'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskService } from '~/services/tasks'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'label', label: 'Libellé', primary: true },
{ key: 'color', label: 'Couleur' },
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
]
const statusService = useTaskStatusService()
const taskService = useTaskService()
const items = ref<TaskStatus[]>([])
const tasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)
const confirmModalOpen = ref(false)
const statusToDelete = ref<TaskStatus | null>(null)
const affectedTaskCount = computed(() => {
if (!statusToDelete.value) return 0
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
})
const reassignTargets = computed(() => {
if (!statusToDelete.value) return items.value
return items.value.filter(s => s.id !== statusToDelete.value!.id)
})
async function loadItems() {
isLoading.value = true
try {
const [statuses, allTasks] = await Promise.all([
statusService.getAll(),
taskService.getAll(),
])
items.value = statuses
tasks.value = allTasks
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: TaskStatus) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: TaskStatus) {
statusToDelete.value = item
const count = tasks.value.filter(t => t.status?.id === item.id).length
if (count === 0) {
await statusService.remove(item.id)
await loadItems()
} else {
confirmModalOpen.value = true
}
}
async function onConfirmDelete(targetStatusId: number | null) {
if (!statusToDelete.value) return
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
await Promise.all(
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
)
await statusService.remove(statusToDelete.value.id)
confirmModalOpen.value = false
statusToDelete.value = null
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -2,12 +2,13 @@
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Tags</h2> <h2 class="text-lg font-bold text-neutral-900">Tags</h2>
<button <MalioButton
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un tag"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un tag
</button>
</div> </div>
<DataTable <DataTable

View File

@@ -2,12 +2,13 @@
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2> <h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
<button <MalioButton
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un utilisateur"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un utilisateur
</button>
</div> </div>
<DataTable <DataTable

View File

@@ -0,0 +1,100 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('workflows.addWorkflow')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun workflow trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-isDefault="{ item }">
<span
v-if="item.isDefault"
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
>
{{ $t('workflows.isDefault') }}
</span>
</template>
<template #cell-statusCount="{ item }">
{{ item.statuses.length }}
</template>
</DataTable>
<WorkflowDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns: DataTableColumn[] = [
{ key: 'name', label: t('workflows.name'), primary: true },
{ key: 'isDefault', label: t('workflows.isDefault') },
{ key: 'statusCount', label: t('workflows.statuses') },
{ key: 'position', label: 'Position' },
]
const workflowService = useWorkflowService()
const items = ref<Workflow[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Workflow | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await workflowService.getAll()
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Workflow) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: Workflow) {
try {
await workflowService.remove(item.id)
await loadItems()
} catch {
// Toast d'erreur déjà émis par useApi
}
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -0,0 +1,121 @@
<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>
<MalioInputPassword
v-model="form.password"
:label="$t('zimbra.settings.password')"
input-class="w-full"
/>
<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">
<MalioButton
:label="$t('zimbra.settings.save')"
button-class="w-auto px-4"
:disabled="isSaving"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('zimbra.settings.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService()
const form = reactive({
serverUrl: '',
username: '',
calendarPath: '',
password: '',
enabled: false,
})
const hasPassword = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)
async function loadSettings() {
const settings = await getSettings()
form.serverUrl = settings.serverUrl ?? ''
form.username = settings.username ?? ''
form.calendarPath = settings.calendarPath ?? ''
form.enabled = settings.enabled
hasPassword.value = settings.hasPassword
}
async function handleSave() {
isSaving.value = true
try {
const result = await saveSettings({
serverUrl: form.serverUrl.trim() || null,
username: form.username.trim() || null,
calendarPath: form.calendarPath.trim() || null,
password: form.password || null,
enabled: form.enabled,
})
hasPassword.value = result.hasPassword
form.password = ''
testResult.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
try {
const result = await testConnection()
testResult.value = result.success
} catch {
testResult.value = false
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>

View File

@@ -0,0 +1,261 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.name"
:label="$t('workflows.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
@blur="touched.name = true"
/>
<div class="flex items-center gap-2">
<input
id="isDefault"
v-model="form.isDefault"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isDefault" class="text-sm font-medium text-neutral-700">
{{ $t('workflows.isDefault') }}
</label>
</div>
<div class="mt-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
<MalioButton
type="button"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.addStatus')"
@click="addStatus"
/>
</div>
<div class="mt-3 flex flex-col gap-3">
<div
v-for="(s, idx) in form.statuses"
:key="idx"
class="rounded border border-neutral-200 p-3"
>
<div class="flex items-end gap-2">
<MalioInputText
v-model="s.label"
label="Libellé"
input-class="w-full"
/>
<select
v-model="s.category"
class="h-10 rounded border border-neutral-300 px-2 text-sm"
aria-label="Catégorie"
>
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</option>
</select>
<button
type="button"
class="h-10 px-2 text-red-600 hover:text-red-800"
aria-label="Supprimer"
@click="removeStatus(idx)"
>
<Icon name="mdi:delete" size="20" />
</button>
</div>
<div class="mt-2 flex items-center gap-3">
<ColorPicker v-model="s.color" />
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
{{ $t('archive.statusFinal') }}
</label>
<label class="flex flex-col text-xs text-neutral-700">
Position
<input
v-model.number="s.position"
type="number"
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
/>
</label>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
item: Workflow | null
}>()
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.item)
const isSubmitting = ref(false)
type StatusForm = {
id?: number
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
}
const form = reactive<{
name: string
isDefault: boolean
statuses: StatusForm[]
}>({
name: '',
isDefault: false,
statuses: [],
})
const touched = reactive({ name: false })
const categoryOptions: { value: StatusCategory, label: string }[] = [
{ value: 'todo', label: t('workflows.categories.todo') },
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
{ value: 'blocked', label: t('workflows.categories.blocked') },
{ value: 'review', label: t('workflows.categories.review') },
{ value: 'done', label: t('workflows.categories.done') },
]
watch(() => props.modelValue, (open) => {
if (!open) return
if (props.item) {
form.name = props.item.name
form.isDefault = props.item.isDefault
form.statuses = props.item.statuses.map(s => ({
id: s.id,
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
}))
} else {
form.name = ''
form.isDefault = false
form.statuses = []
}
touched.name = false
})
function addStatus() {
form.statuses.push({
label: '',
color: '#222783',
position: form.statuses.length,
isFinal: false,
category: 'todo',
})
}
function removeStatus(idx: number) {
form.statuses.splice(idx, 1)
}
const workflowService = useWorkflowService()
const statusService = useTaskStatusService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
await workflowService.update(props.item.id, {
name: form.name.trim(),
isDefault: form.isDefault,
position: props.item.position,
})
await syncStatuses(props.item)
} else {
const created = await workflowService.create({
name: form.name.trim(),
isDefault: form.isDefault,
position: 0,
})
for (const s of form.statuses) {
const payload: TaskStatusWrite = {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${created.id}`,
}
await statusService.create(payload)
}
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function syncStatuses(workflow: Workflow) {
const existingIds = new Set(workflow.statuses.map(s => s.id))
const keptIds = new Set<number>()
for (const s of form.statuses) {
if (s.id) {
keptIds.add(s.id)
await statusService.update(s.id, {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
})
} else {
await statusService.create({
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${workflow.id}`,
})
}
}
for (const id of existingIds) {
if (id && !keptIds.has(id)) {
await statusService.remove(id)
}
}
}
</script>

View File

@@ -29,22 +29,22 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) --> <!-- Edit button (only for open tickets submitted by current user) -->
<button <MalioButton
v-if="canEdit && !isEditing" v-if="canEdit && !isEditing"
type="button" variant="tertiary"
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50" icon-name="mdi:pencil-outline"
icon-position="left"
button-class="w-auto px-3"
:label="$t('common.edit')"
@click="startEdit" @click="startEdit"
> />
<Icon name="mdi:pencil-outline" size="16" /> <MalioButtonIcon
{{ $t('common.edit') }} icon="mdi:close"
</button> aria-label="Fermer"
<button variant="ghost"
type="button" icon-size="20"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close" @click="close"
> />
<Icon name="mdi:close" size="20" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -66,13 +66,10 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700"> <MalioInputRichText
{{ $t('clientTicket.description') }}
</label>
<textarea
v-model="editForm.description" v-model="editForm.description"
rows="5" :label="$t('clientTicket.description')"
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" min-height="180px"
/> />
</div> </div>
@@ -89,21 +86,18 @@
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button <MalioButton
type="button" variant="tertiary"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50" :label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancelEdit" @click="cancelEdit"
> />
{{ $t('common.cancel') }} <MalioButton
</button> :label="$t('common.save')"
<button button-class="w-auto px-6"
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSaving" :disabled="isSaving"
@click="saveEdit" @click="saveEdit"
> />
{{ $t('common.save') }}
</button>
</div> </div>
</template> </template>
@@ -131,7 +125,13 @@
<!-- Description --> <!-- Description -->
<div class="mt-4"> <div class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p> <p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p> <MalioInputRichText
v-if="ticket.description"
:model-value="ticket.description"
:editable="false"
group-class="mt-1"
/>
<p v-else class="mt-1 text-sm italic text-neutral-400"></p>
</div> </div>
<!-- URL (if bug) --> <!-- URL (if bug) -->
@@ -191,7 +191,7 @@
</template> </template>
<script setup lang="ts"> <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 type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets' import { useClientTicketService } from '~/services/client-tickets'
@@ -243,7 +243,7 @@ const canEdit = computed(() => {
if (!sub) return false if (!sub) return false
// submittedBy can be an IRI string or an embedded object // submittedBy can be an IRI string or an embedded object
if (typeof sub === 'string') return sub === `/api/users/${userId}` 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 return false
}) })
@@ -270,7 +270,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') { if (props.ticket.type === 'bug') {
data.url = editForm.url || null 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 isEditing.value = false
emit('refresh') emit('refresh')
} finally { } finally {

View File

@@ -1,11 +1,13 @@
<template> <template>
<div> <div>
<!-- Trigger button --> <!-- Trigger button -->
<button <MalioButton
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4" variant="tertiary"
icon-name="mdi:ticket-outline"
icon-position="left"
button-class="w-auto px-3 sm:px-4 shrink-0"
@click="open" @click="open"
> >
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span> <span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span <span
v-if="totalCount > 0" v-if="totalCount > 0"
@@ -13,7 +15,7 @@
> >
{{ totalCount }} {{ totalCount }}
</span> </span>
</button> </MalioButton>
<!-- Panel --> <!-- Panel -->
<Teleport v-if="isOpen" to="body"> <Teleport v-if="isOpen" to="body">
@@ -33,13 +35,13 @@
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2> <h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p> <p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div> </div>
<button <MalioButtonIcon
type="button" icon="mdi:close"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600" aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close" @click="close"
> />
<Icon name="mdi:close" size="20" />
</button>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -97,13 +99,13 @@
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p> <p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <MalioButtonIcon
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500" icon="mdi:swap-horizontal"
:title="$t('clientTicket.changeStatus')" :aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="16"
@click.stop="openStatusChange(ticket)" @click.stop="openStatusChange(ticket)"
> />
<Icon name="mdi:swap-horizontal" size="16" />
</button>
<Icon <Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'" :name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18" size="18"
@@ -114,7 +116,12 @@
<!-- Expanded details --> <!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3"> <div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p> <MalioInputRichText
v-if="ticket.description"
:model-value="ticket.description"
:editable="false"
/>
<p v-else class="text-sm italic text-neutral-400"></p>
<div v-if="ticket.url" class="mt-2"> <div v-if="ticket.url" class="mt-2">
<a <a
:href="ticket.url" :href="ticket.url"
@@ -179,19 +186,18 @@
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<button <MalioButton
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50" variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false" @click="statusModalOpen = false"
> />
{{ $t('common.cancel') }} <MalioButton
</button> label="Confirmer"
<button button-class="w-auto px-6"
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" :disabled="isUpdatingStatus"
@click="confirmStatusChange" @click="confirmStatusChange"
> />
Confirmer
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
@@ -35,16 +35,15 @@
/> />
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <MalioButton
type="submit" label="Enregistrer"
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" button-class="w-auto px-6"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group'
import type { TaskPriority } from '~/services/dto/task-priority'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{
/** v-model: true = modal ouvert */
modelValue: boolean
/** ID BDD du message source */
messageId: number
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après création réussie — payload = tâche créée */
created: [task: Task]
}>()
const { t } = useI18n()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const priorityService = useTaskPriorityService()
// ─── État formulaire ──────────────────────────────────────────────────────
const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const priorityId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)
// ─── Données de référence ─────────────────────────────────────────────────
const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const priorities = ref<TaskPriority[]>([])
const loadingGroups = ref(false)
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id })),
)
const groupOptions = computed(() =>
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
)
const priorityOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id })),
)
// ─── Chargement initial ───────────────────────────────────────────────────
onMounted(async () => {
const [projs, prios] = await Promise.all([
projectService.getAll({ archived: false }),
priorityService.getAll(),
])
projects.value = projs
priorities.value = prios
})
// Recharger les groupes quand le projet change
watch(projectId, async (pid) => {
taskGroupId.value = null
groups.value = []
if (!pid) return
loadingGroups.value = true
try {
groups.value = await taskGroupService.getByProject(pid)
} finally {
loadingGroups.value = false
}
})
// Reset formulaire à l'ouverture
watch(() => props.modelValue, (open) => {
if (open) {
projectId.value = null
taskGroupId.value = null
priorityId.value = null
touchedProject.value = false
}
})
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void {
emit('update:modelValue', false)
}
async function handleSubmit(): Promise<void> {
touchedProject.value = true
if (!projectId.value) return
isSubmitting.value = true
try {
const task = await mailService.createTaskFromMail(props.messageId, {
projectId: projectId.value,
taskGroupId: taskGroupId.value ?? undefined,
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
})
emit('created', task)
close()
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
style="max-height: min(90vh, 640px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
<h2 class="text-base font-bold text-neutral-900">
{{ t('mail.createTaskModal.title') }}
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Corps -->
<div class="overflow-y-auto px-6 py-5 space-y-5">
<!-- Info mail source (lecture seule) -->
<div
v-if="messageDetail"
class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
>
<p class="font-medium text-neutral-800 truncate">
{{ messageDetail.header.subject ?? t('mail.noSubject') }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
</p>
<p class="mt-2 text-xs text-neutral-400 italic">
{{ t('mail.createTaskModal.titleHint') }}
</p>
<p class="text-xs text-neutral-400 italic">
{{ t('mail.createTaskModal.descriptionHint') }}
</p>
</div>
<!-- Sélection projet -->
<div>
<MalioSelect
v-model="projectId"
:options="projectOptions"
:label="t('mail.createTaskModal.projectLabel')"
:empty-option-label="t('mail.createTaskModal.projectPlaceholder')"
min-width="w-full"
/>
<p
v-if="touchedProject && !projectId"
class="mt-1 text-xs text-red-500"
>
{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis
</p>
</div>
<!-- Sélection groupe (optionnel, chargé après projet) -->
<div v-if="projectId">
<MalioSelect
v-model="taskGroupId"
:options="groupOptions"
:label="t('mail.createTaskModal.groupLabel')"
:empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
min-width="w-full"
:disabled="loadingGroups"
/>
</div>
<!-- Sélection priorité (optionnelle) MalioSelect car les values sont number | null -->
<div>
<MalioSelect
v-model="priorityId"
:options="priorityOptions"
:label="t('mail.createTaskModal.priorityLabel')"
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
min-width="w-full"
/>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="t('mail.createTaskModal.submit')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-modal-enter-active,
.mail-modal-leave-active {
transition: opacity 0.2s ease;
}
.mail-modal-enter-active > div:last-child,
.mail-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.mail-modal-enter-from,
.mail-modal-leave-to {
opacity: 0;
}
.mail-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */
folders: readonly MailFolderDto[]
/** Chemin du dossier actuellement sélectionné */
selectedPath: string | null
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
depth?: number
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
const { t } = useI18n()
const currentDepth = computed(() => props.depth ?? 0)
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
const expanded = ref<Set<string>>(new Set())
function isExpanded(path: string): boolean {
return expanded.value.has(path)
}
function toggleExpanded(path: string): void {
const next = new Set(expanded.value)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
expanded.value = next
}
function hasChildren(folder: MailFolderDto): boolean {
return !!folder.children && folder.children.length > 0
}
function handleSelect(path: string): void {
emit('select', path)
}
function paddingStyle(): Record<string, string> {
const depth = currentDepth.value
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
}
</script>
<template>
<div>
<div
v-if="folders.length === 0 && currentDepth === 0"
class="px-3 py-4 text-sm text-neutral-400 italic"
>
{{ t('mail.empty.folder') }}
</div>
<template v-else>
<div v-for="folder in folders" :key="folder.path">
<div
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
:class="
selectedPath === folder.path
? 'bg-primary-100 text-primary-700 font-medium'
: 'text-neutral-700 hover:bg-neutral-100'
"
:style="paddingStyle()"
>
<button
v-if="hasChildren(folder)"
type="button"
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
@click.stop="toggleExpanded(folder.path)"
>
<Icon
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
size="16"
class="text-neutral-400"
/>
</button>
<span v-else class="inline-block w-[22px] flex-shrink-0" />
<button
type="button"
class="flex flex-1 items-center gap-2 text-left min-w-0"
@click="handleSelect(folder.path)"
>
<Icon
:name="getFolderIcon(folder.path)"
size="16"
class="flex-shrink-0"
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
/>
<span class="flex-1 truncate">
{{ getFolderLabel(folder.path, folder.displayName) }}
</span>
<span
v-if="folder.unreadCount > 0"
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
>
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
</span>
</button>
</div>
<MailFolderTree
v-if="hasChildren(folder) && isExpanded(folder.path)"
:folders="folder.children"
:selected-path="selectedPath"
:depth="currentDepth + 1"
@select="handleSelect"
/>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,266 @@
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import { useTaskService } from '~/services/tasks'
import { useProjectService } from '~/services/projects'
const props = defineProps<{
modelValue: boolean
/** ID BDD du message à lier */
messageId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après liaison réussie — payload = id de la tâche liée */
linked: [taskId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const taskService = useTaskService()
const projectService = useProjectService()
// ─── État recherche ───────────────────────────────────────────────────────
const searchQuery = ref('')
const filterProjectId = ref<number | null>(null)
const results = ref<Task[]>([])
const selectedTask = ref<Task | null>(null)
const isLoading = ref(false)
const isSubmitting = ref(false)
// ─── Projets pour le filtre ───────────────────────────────────────────────
const projects = ref<Project[]>([])
const projectFilterOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id })),
)
onMounted(async () => {
projects.value = await projectService.getAll({ archived: false })
})
// ─── Debounce recherche ───────────────────────────────────────────────────
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch([searchQuery, filterProjectId], () => {
selectedTask.value = null
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
void runSearch()
}, 300)
})
async function runSearch(): Promise<void> {
const q = searchQuery.value.trim()
if (!q && !filterProjectId.value) {
results.value = []
return
}
isLoading.value = true
try {
const params: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (q) params['title'] = q
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
results.value = await taskService.getFiltered(params)
} finally {
isLoading.value = false
}
}
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
watch(() => props.modelValue, (open) => {
if (open) {
searchQuery.value = ''
filterProjectId.value = null
results.value = []
selectedTask.value = null
}
})
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void {
emit('update:modelValue', false)
}
function selectTask(task: Task): void {
selectedTask.value = task
}
async function handleSubmit(): Promise<void> {
if (!selectedTask.value) return
isSubmitting.value = true
try {
await mailService.linkTask(props.messageId, selectedTask.value.id)
emit('linked', selectedTask.value.id)
close()
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<div
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
style="max-height: min(90vh, 640px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
<h2 class="text-base font-bold text-neutral-900">
{{ t('mail.linkTaskModal.title') }}
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Corps -->
<div class="overflow-y-auto px-6 py-5 space-y-4">
<!-- Filtre projet -->
<MalioSelect
v-model="filterProjectId"
:options="projectFilterOptions"
:label="t('mail.linkTaskModal.projectFilter')"
:empty-option-label="t('mail.linkTaskModal.projectAll')"
min-width="w-full"
/>
<!-- Recherche tâche -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ t('mail.linkTaskModal.title') }}
</label>
<input
v-model="searchQuery"
type="text"
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
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>
<!-- Résultats -->
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
<!-- Chargement -->
<div
v-if="isLoading"
class="flex items-center justify-center py-6 text-sm text-neutral-400"
>
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
{{ t('mail.linkTaskModal.loading') }}
</div>
<!-- Vide -->
<div
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
class="py-6 text-center text-sm text-neutral-400 italic"
>
{{ t('mail.linkTaskModal.empty') }}
</div>
<!-- Liste résultats -->
<button
v-for="task in results"
:key="task.id"
type="button"
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
:class="selectedTask?.id === task.id
? 'bg-primary-50 border-l-2 border-primary-500'
: 'border-l-2 border-transparent'"
@click="selectTask(task)"
>
<Icon
name="material-symbols:task-outline"
size="16"
class="mt-0.5 flex-shrink-0 text-neutral-400"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-neutral-800">
{{ task.title }}
</p>
<p
v-if="task.project"
class="truncate text-xs text-neutral-500"
>
{{ task.project.name }}
<span v-if="task.project.code && task.number">
{{ task.project.code }}-{{ task.number }}
</span>
</p>
</div>
<Icon
v-if="selectedTask?.id === task.id"
name="material-symbols:check-circle"
size="16"
class="flex-shrink-0 text-primary-500"
/>
</button>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="t('mail.linkTaskModal.submit')"
button-class="w-auto px-6"
:disabled="!selectedTask || isSubmitting"
@click="handleSubmit"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-modal-enter-active,
.mail-modal-leave-active {
transition: opacity 0.2s ease;
}
.mail-modal-enter-active > div:last-child,
.mail-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.mail-modal-enter-from,
.mail-modal-leave-to {
opacity: 0;
}
.mail-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
messages: readonly MailMessageHeaderDto[]
selectedId: number | null
loading: boolean
hasMore: boolean
}>()
const emit = defineEmits<{
select: [id: number]
loadMore: []
}>()
const { t } = useI18n()
const sentinelRef = ref<HTMLDivElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(() => {
if (!sentinelRef.value) return
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting && props.hasMore && !props.loading) {
emit('loadMore')
}
},
{ threshold: 0.1 },
)
observer.observe(sentinelRef.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
/**
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
* Utilise Intl.RelativeTimeFormat avec la locale fr.
*/
function formatRelative(isoDate: string | null): string {
if (!isoDate) return ''
const date = new Date(isoDate)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffSeconds = Math.round(diffMs / 1000)
const diffMinutes = Math.round(diffSeconds / 60)
const diffHours = Math.round(diffMinutes / 60)
const diffDays = Math.round(diffHours / 24)
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
}
function getSenderLabel(msg: MailMessageHeaderDto): string {
return msg.fromName ?? msg.fromEmail ?? ''
}
</script>
<template>
<div class="flex h-full flex-col overflow-hidden">
<div
v-if="!loading && messages.length === 0"
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
>
{{ t('mail.empty.list') }}
</div>
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
<button
v-for="msg in messages"
:key="msg.id"
type="button"
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
:class="[
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
]"
@click="emit('select', msg.id)"
>
<div class="mt-1.5 flex-shrink-0">
<span
class="block h-2 w-2 rounded-full"
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span
class="truncate text-sm"
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
>
{{ getSenderLabel(msg) }}
</span>
<span class="flex-shrink-0 text-xs text-neutral-400">
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
</span>
</div>
<p
class="truncate text-sm"
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
>
{{ msg.subject ?? t('mail.noSubject') }}
</p>
<div class="mt-0.5 flex items-center gap-1.5">
<Icon
v-if="msg.isFlagged"
name="material-symbols:star"
size="14"
class="text-amber-400 flex-shrink-0"
/>
<Icon
v-if="msg.hasAttachments"
name="material-symbols:attach-file"
size="14"
class="text-neutral-400 flex-shrink-0"
/>
<Icon
v-if="msg.linkedTaskIds.length > 0"
name="material-symbols:task-outline"
size="14"
class="text-primary-400 flex-shrink-0"
/>
</div>
</div>
</button>
<div ref="sentinelRef" class="h-px" />
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
</div>
</div>
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
</div>
</div>
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/services/mail'
const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */
detail: MailMessageDetailDto | null
loading: boolean
}>()
const emit = defineEmits<{
createTask: [mailId: number]
linkTask: [mailId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const showImages = ref(false)
const sanitizedBody = computed((): string => {
if (!props.detail?.bodyHtml) return ''
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
})
watch(
() => props.detail?.header.id,
() => {
showImages.value = false
},
)
async function handleDownload(downloadId: string, filename: string): Promise<void> {
try {
const { data } = await mailService.downloadAttachment(downloadId)
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
// L'erreur est gérée par useApi (toast automatique)
}
}
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleString('fr', {
dateStyle: 'long',
timeStyle: 'short',
})
}
function joinAddresses(addresses: MailAddressDto[]): string {
return addresses
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
.join(', ')
}
</script>
<template>
<div class="flex h-full flex-col overflow-hidden">
<div
v-if="!detail && !loading"
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
>
{{ t('mail.empty.viewer') }}
</div>
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
</div>
<template v-else-if="detail">
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
<h2 class="text-base font-semibold text-neutral-900 break-words">
{{ detail.header.subject ?? t('mail.noSubject') }}
</h2>
<dl class="text-xs text-neutral-500 space-y-0.5">
<div class="flex gap-1.5">
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
<dd class="break-all">
{{
detail.header.fromName
? `${detail.header.fromName} <${detail.header.fromEmail}>`
: (detail.header.fromEmail ?? '')
}}
</dd>
</div>
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
</div>
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
</div>
<div class="flex gap-1.5">
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
</div>
</dl>
<div class="flex flex-wrap items-center gap-2 pt-1">
<MalioButton
:label="t('mail.actions.createTask')"
variant="primary"
icon-name="material-symbols:add-task-outline"
icon-position="left"
:icon-size="13"
button-class="text-xs px-2.5 py-1"
@click="emit('createTask', detail.header.id)"
/>
<MalioButton
:label="t('mail.actions.linkTask')"
variant="secondary"
icon-name="material-symbols:link"
icon-position="left"
:icon-size="13"
button-class="text-xs px-2.5 py-1"
@click="emit('linkTask', detail.header.id)"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-3">
<div
v-if="!showImages && detail.bodyHtml"
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
>
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
<span class="flex-1 text-amber-700">
{{ t('mail.remoteImagesBlocked') }}
</span>
<button
type="button"
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
@click="showImages = true"
>
{{ t('mail.actions.showImages') }}
</button>
</div>
<div
v-if="detail.bodyHtml"
class="prose prose-sm max-w-none text-neutral-800"
v-html="sanitizedBody"
/>
<pre
v-else-if="detail.bodyText"
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
>{{ detail.bodyText }}</pre>
</div>
<div
v-if="detail.attachments.length > 0"
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="att in detail.attachments"
:key="att.downloadId"
type="button"
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
:title="att.filename"
@click="handleDownload(att.downloadId, att.filename)"
>
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
</button>
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/services/dto/mail'
import { useMailService } from '~/services/mail'
import { useMailStore } from '~/stores/mail'
const props = defineProps<{
modelValue: boolean
/** ID de la tâche cible (destinataire du lien) */
taskId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après liaison réussie — payload = id du message lié */
linked: [messageId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const mailStore = useMailStore()
// ─── État ─────────────────────────────────────────────────────────────────
const searchQuery = ref('')
const allMessages = ref<MailMessageHeaderDto[]>([])
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
const isLoading = ref(false)
const isSubmitting = ref(false)
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
const filteredMessages = computed(() => {
const q = searchQuery.value.toLowerCase().trim()
if (!q) return allMessages.value
return allMessages.value.filter(
(m) =>
(m.subject ?? '').toLowerCase().includes(q)
|| (m.fromName ?? '').toLowerCase().includes(q)
|| (m.fromEmail ?? '').toLowerCase().includes(q),
)
})
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
watch(() => props.modelValue, async (open) => {
if (!open) return
searchQuery.value = ''
selectedMessage.value = null
isLoading.value = true
try {
// Utiliser le dossier actuellement sélectionné dans le store si disponible,
// sinon fallback sur INBOX.
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
const page = await mailService.listMessages(folderPath, undefined, 50)
allMessages.value = page.items
} finally {
isLoading.value = false
}
})
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void {
emit('update:modelValue', false)
}
function selectMessage(msg: MailMessageHeaderDto): void {
selectedMessage.value = msg
}
async function handleSubmit(): Promise<void> {
if (!selectedMessage.value) return
isSubmitting.value = true
try {
await mailService.linkTask(selectedMessage.value.id, props.taskId)
emit('linked', selectedMessage.value.id)
close()
} finally {
isSubmitting.value = false
}
}
// ─── Formatage ────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<div
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
style="max-height: min(90vh, 640px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
<h2 class="text-base font-bold text-neutral-900">
{{ t('mail.pickerModal.title') }}
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Corps -->
<div class="overflow-y-auto px-6 py-5 space-y-4">
<!-- Recherche locale -->
<input
v-model="searchQuery"
type="text"
:placeholder="t('mail.pickerModal.searchPlaceholder')"
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"
/>
<!-- Résultats -->
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
<!-- Chargement -->
<div
v-if="isLoading"
class="flex items-center justify-center py-8 text-sm text-neutral-400"
>
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
{{ t('mail.pickerModal.loading') }}
</div>
<!-- Vide -->
<div
v-else-if="filteredMessages.length === 0"
class="py-8 text-center text-sm text-neutral-400 italic"
>
{{ t('mail.pickerModal.empty') }}
</div>
<!-- Liste -->
<button
v-for="msg in filteredMessages"
:key="msg.id"
type="button"
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
:class="selectedMessage?.id === msg.id
? 'bg-primary-50 border-l-2 border-primary-500'
: 'border-l-2 border-transparent'"
@click="selectMessage(msg)"
>
<Icon
name="material-symbols:mail-outline"
size="16"
class="mt-0.5 flex-shrink-0 text-neutral-400"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-neutral-800">
{{ msg.subject ?? t('mail.noSubject') }}
</p>
<p class="flex items-center gap-2 text-xs text-neutral-500">
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
<span class="flex-shrink-0">·</span>
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
</p>
</div>
<Icon
v-if="selectedMessage?.id === msg.id"
name="material-symbols:check-circle"
size="16"
class="flex-shrink-0 text-primary-500"
/>
</button>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="t('mail.pickerModal.submit')"
button-class="w-auto px-6"
:disabled="!selectedMessage || isSubmitting"
@click="handleSubmit"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-modal-enter-active,
.mail-modal-leave-active {
transition: opacity 0.2s ease;
}
.mail-modal-enter-active > div:last-child,
.mail-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.mail-modal-enter-from,
.mail-modal-leave-to {
opacity: 0;
}
.mail-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useMailStore } from '~/stores/mail'
const store = useMailStore()
const { syncing } = storeToRefs(store)
const { t } = useI18n()
async function handleRefresh(): Promise<void> {
await store.triggerSync()
}
</script>
<template>
<MalioButton
:label="t('mail.actions.refresh')"
variant="secondary"
icon-name="material-symbols:refresh"
icon-position="left"
:icon-size="16"
:disabled="syncing"
@click="handleRefresh"
/>
</template>

View File

@@ -1,18 +1,21 @@
<template> <template>
<div ref="bellRef" class="relative"> <div ref="bellRef" class="relative">
<button <div class="relative">
type="button" <MalioButtonIcon
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors" icon="mdi:bell-outline"
@click="toggleDropdown" aria-label="Notifications"
> variant="ghost"
<Icon name="mdi:bell-outline" size="24" /> icon-size="24"
button-class="text-white hover:bg-primary-600"
@click="toggleDropdown"
/>
<span <span
v-if="unreadCount > 0" v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white" class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white pointer-events-none"
> >
{{ unreadCount > 99 ? '99+' : unreadCount }} {{ unreadCount > 99 ? '99+' : unreadCount }}
</span> </span>
</button> </div>
<Transition name="dropdown"> <Transition name="dropdown">
<div <div

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.code" v-model="form.code"
@@ -54,27 +54,69 @@
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <MalioButton
type="submit" label="Enregistrer"
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" button-class="w-auto px-6"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4"> <div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button <MalioButton
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600" variant="tertiary"
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleArchiveToggle" @click="handleArchiveToggle"
> >
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }} {{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button> </MalioButton>
<MalioButton
v-if="project.taskCount === 0"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
{{ $t('common.delete') }}
</MalioButton>
</div> </div>
</AppDrawer>
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
</div>
<MalioButton
v-if="canManageWorkflows"
type="button"
icon-name="mdi:swap-horizontal"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.switchTitle')"
@click="switchModalOpen = true"
/>
</div>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
<ProjectWorkflowSwitchModal
v-if="props.project"
v-model="switchModalOpen"
:project="props.project"
@switched="onWorkflowSwitched"
/>
</MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -104,6 +146,16 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const switchModalOpen = ref(false)
const auth = useAuthStore()
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
function onWorkflowSwitched() {
emit('saved')
isOpen.value = false
}
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +216,7 @@ watch(() => props.modelValue, (open) => {
} }
}) })
const { create, update } = useProjectService() const { create, update, remove } = useProjectService()
async function handleSubmit() { async function handleSubmit() {
touched.name = true touched.name = true
@@ -213,6 +265,19 @@ async function handleSubmit() {
} }
} }
async function handleDelete() {
if (!props.project) return
isSubmitting.value = true
try {
await remove(props.project.id)
emit('saved')
isOpen.value = false
} finally {
confirmDeleteOpen.value = false
isSubmitting.value = false
}
}
async function handleArchiveToggle() { async function handleArchiveToggle() {
if (!props.project) return if (!props.project) return
isSubmitting.value = true isSubmitting.value = true

View File

@@ -3,20 +3,20 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2> <h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <MalioButton
type="button" variant="tertiary"
class="text-sm font-medium text-neutral-500 hover:text-neutral-700" button-class="w-auto px-3"
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
@click="showArchived = !showArchived" @click="showArchived = !showArchived"
> />
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }} <MalioButton
</button>
<button
v-if="!showArchived" v-if="!showArchived"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un groupe"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un groupe
</button>
</div> </div>
</div> </div>
@@ -36,25 +36,23 @@
/> />
</template> </template>
<template #cell-description="{ item }"> <template #cell-description="{ item }">
{{ item.description ?? '—' }} {{ stripRichText(item.description) || '—' }}
</template> </template>
<template #actions="{ item }"> <template #actions="{ item }">
<button <MalioButton
v-if="!showArchived && canArchiveGroup(item)" v-if="!showArchived && canArchiveGroup(item)"
type="button" variant="secondary"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600" :label="$t('archive.archiveButton')"
button-class="w-auto px-3"
@click.stop="handleArchive(item)" @click.stop="handleArchive(item)"
> />
{{ $t('archive.archiveButton') }} <MalioButton
</button>
<button
v-if="showArchived" v-if="showArchived"
type="button" variant="secondary"
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600" :label="$t('archive.unarchiveButton')"
button-class="w-auto px-3"
@click.stop="handleUnarchive(item)" @click.stop="handleUnarchive(item)"
> />
{{ $t('archive.unarchiveButton') }}
</button>
</template> </template>
</DataTable> </DataTable>
@@ -73,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
projectId: number projectId: number

View File

@@ -0,0 +1,209 @@
<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="close" />
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
<div class="mt-5 flex flex-col gap-5">
<MalioSelect
v-model="targetWorkflowId"
:options="targetOptions"
:label="$t('workflows.switchTargetLabel')"
empty-option-label=""
min-width="!w-full"
/>
<div v-if="targetWorkflow" class="flex flex-col gap-2">
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-neutral-500">
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
<td class="py-2 pr-3">
<span
v-if="row.source"
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
:style="{ backgroundColor: row.source.color }"
/>
{{ row.source?.label ?? $t('myTasks.backlog') }}
<span class="ml-1 text-xs text-neutral-400">
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
</span>
</td>
<td class="py-2 pr-3">
<select
v-model="row.targetId"
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
>
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
<option
v-for="s in targetWorkflow.statuses"
:key="s.id"
:value="s.id"
>
{{ s.label }}
</option>
</select>
</td>
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="$t('workflows.switchConfirm')"
button-class="w-auto px-6"
:disabled="!canConfirm || isSubmitting"
@click="confirm"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
project: Project
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'switched'): void
}>()
const workflows = ref<Workflow[]>([])
const projectTasks = ref<Task[]>([])
const targetWorkflowId = ref<number | null>(null)
const isSubmitting = ref(false)
const workflowService = useWorkflowService()
const taskService = useTaskService()
const targetOptions = computed(() =>
workflows.value
.filter(w => w.id !== props.project.workflow.id)
.map(w => ({ label: w.name, value: w.id })),
)
const targetWorkflow = computed<Workflow | null>(() =>
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
)
type Row = {
sourceId: number | null
source: TaskStatus | null
targetId: number | null
count: number
}
const mappingRows = ref<Row[]>([])
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
if (!source) return null
const sameCat = target.statuses
.filter(s => s.category === source.category)
.sort((a, b) => a.position - b.position)
return sameCat[0]?.id ?? null
}
watch(targetWorkflow, (tw) => {
if (!tw) {
mappingRows.value = []
return
}
const usedStatusIds = new Map<number | null, number>()
for (const t of projectTasks.value) {
const key = t.status?.id ?? null
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
}
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
return {
sourceId,
source,
targetId: smartPrefill(source, tw),
count,
}
})
})
const canConfirm = computed(() => {
if (!targetWorkflow.value) return false
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
})
watch(() => props.modelValue, async (open) => {
if (!open) return
targetWorkflowId.value = null
const [allWorkflows, tasks] = await Promise.all([
workflowService.getAll(),
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
])
workflows.value = allWorkflows
projectTasks.value = tasks
})
function close() {
emit('update:modelValue', false)
}
async function confirm() {
if (!targetWorkflow.value) return
isSubmitting.value = true
try {
const mapping: Record<string, number | null> = {}
for (const r of mappingRows.value) {
if (r.sourceId !== null) {
mapping[String(r.sourceId)] = r.targetId
}
}
await workflowService.switchOnProject(props.project.id, {
workflowId: targetWorkflow.value.id,
mapping,
})
emit('switched')
close()
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.15s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -57,13 +57,14 @@
> >
{{ link.title }} {{ link.title }}
</a> </a>
<button <MalioButtonIcon
type="button" icon="mdi:close"
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500" aria-label="Supprimer le lien"
variant="ghost"
icon-size="16"
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
@click="handleRemove(link.id)" @click="handleRemove(link.id)"
> />
<Icon name="mdi:close" size="16" />
</button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,166 @@
<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 (scoped to single project's workflow) -->
<MalioSelect
v-if="!isMultiProject"
: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)"
/>
<span
v-else
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
title="Sélection multi-projets le statut dépend du workflow de chaque projet"
>
Status —
</span>
<!-- 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 -->
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer"
variant="ghost"
icon-size="22"
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
@click="emit('bulk-delete')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
const props = withDefaults(defineProps<{
selectedCount: number
totalCount: number
allSelected: boolean
someSelected: boolean
statuses: TaskStatus[]
users: UserData[]
priorities: TaskPriority[]
efforts: TaskEffort[]
groups: TaskGroup[]
selectedTasks?: Task[]
projects?: Project[]
}>(), {
selectedTasks: () => [],
projects: () => [],
})
const emit = defineEmits<{
(e: 'toggle-all'): void
(e: 'bulk-update', field: string, value: number): void
(e: 'bulk-archive'): void
(e: 'bulk-delete'): void
}>()
const distinctProjectIds = computed(() => {
const ids = new Set<number>()
for (const t of props.selectedTasks) {
if (t.project) ids.add(t.project.id)
}
return ids
})
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
const statusOptions = computed<{ label: string, value: number }[]>(() => {
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
const projectId = [...distinctProjectIds.value][0]
const project = props.projects.find(p => p.id === projectId)
if (project?.workflow?.statuses) {
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
}
}
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
return 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="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-1"> <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 <Icon
v-if="task.clientTicket" v-if="task.clientTicket"
name="heroicons:user-circle" name="heroicons:user-circle"
@@ -19,16 +29,24 @@
</div> </div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4> <h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div> </div>
<button <MalioButtonIcon
class="shrink-0 transition-colors" :icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'" :aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()" @click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
> />
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
</div> </div>
<div class="mt-2 flex items-center gap-1.5"> <div class="mt-2 flex items-center gap-1.5">
<span
v-if="showStatusBadge && task.status"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.status.color }"
>
{{ task.status.label }}
</span>
<span <span
v-if="task.priority" v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white" class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
@@ -44,11 +62,40 @@
> >
{{ tag.label }} {{ tag.label }}
</span> </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"
/>
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="ml-auto h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<UserAvatar <UserAvatar
v-if="task.assignee" v-if="task.assignee"
:user="task.assignee" :user="task.assignee"
size="xs" size="xs"
class="ml-auto" :class="task.collaborators?.length ? '' : 'ml-auto'"
/> />
<span <span
v-else v-else
@@ -63,9 +110,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
const props = defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
}>() showProjectColor?: boolean
showStatusBadge?: boolean
}>(), {
showProjectColor: false,
showStatusBadge: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click'): void (e: 'click'): void
@@ -87,6 +139,18 @@ function onPlay() {
timerStore.startFromTask(props.task) timerStore.startFromTask(props.task)
} }
const deadlineColor = computed(() => {
if (!props.task.deadline) return ''
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
if (daysLeft < 0) return '#DC2626'
if (daysLeft < 2) return '#F59E0B'
return '#9CA3AF'
})
function formatDeadline(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
}
function onDragStart(event: DragEvent) { function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move' event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(props.task.id)) event.dataTransfer!.setData('text/plain', String(props.task.id))

View File

@@ -32,14 +32,15 @@
</div> </div>
<!-- Delete button --> <!-- Delete button -->
<button <MalioButtonIcon
v-if="isAdmin" v-if="isAdmin"
type="button" icon="heroicons:x-mark"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block" aria-label="Supprimer"
variant="ghost"
icon-size="16"
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)" @click.stop="$emit('delete', doc)"
> />
<Icon name="heroicons:x-mark" class="h-4 w-4" />
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,28 +12,34 @@
ref="overlayRef" ref="overlayRef"
> >
<!-- Close button --> <!-- Close button -->
<button <MalioButtonIcon
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70" icon="heroicons:x-mark"
aria-label="Fermer"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('close')" @click="$emit('close')"
> />
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
<!-- Navigation arrows --> <!-- Navigation arrows -->
<button <MalioButtonIcon
v-if="hasPrev" v-if="hasPrev"
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70" icon="heroicons:chevron-left"
aria-label="Précédent"
variant="ghost"
icon-size="24"
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('prev')" @click="$emit('prev')"
> />
<Icon name="heroicons:chevron-left" class="h-6 w-6" /> <MalioButtonIcon
</button>
<button
v-if="hasNext" v-if="hasNext"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70" icon="heroicons:chevron-right"
aria-label="Suivant"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('next')" @click="$emit('next')"
> />
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
</button>
<!-- Content --> <!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center"> <div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -10,16 +10,15 @@
/> />
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <MalioButton
type="submit" label="Enregistrer"
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" button-class="w-auto px-6"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -38,24 +38,22 @@
<!-- Actions --> <!-- Actions -->
<div class="flex gap-1"> <div class="flex gap-1">
<button <MalioButtonIcon
v-if="activeTab === 'branches'" v-if="activeTab === 'branches'"
type="button" icon="mdi:content-copy"
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700" :aria-label="$t('gitea.branch.copy')"
:title="$t('gitea.branch.copy')" variant="ghost"
icon-size="14"
@click="handleCopy" @click="handleCopy"
> />
<Icon name="mdi:content-copy" size="14" /> <MalioButton
</button>
<button
v-if="activeTab === 'branches'" v-if="activeTab === 'branches'"
type="button" icon-name="mdi:plus"
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500" icon-position="left"
button-class="w-auto px-2.5 py-1.5 text-xs"
:label="$t('gitea.branch.create')"
@click="showCreateForm = !showCreateForm" @click="showCreateForm = !showCreateForm"
> />
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
{{ $t('gitea.branch.create') }}
</button>
</div> </div>
</div> </div>
@@ -79,14 +77,12 @@
:label="$t('gitea.branch.baseBranch')" :label="$t('gitea.branch.baseBranch')"
input-class="w-full" input-class="w-full"
/> />
<button <MalioButton
type="button" :label="isCreating ? '...' : $t('gitea.branch.create')"
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50" button-class="w-auto px-4 mb-[2px] text-xs"
:disabled="isCreating" :disabled="isCreating"
@click="handleCreate" @click="handleCreate"
> />
{{ isCreating ? '...' : $t('gitea.branch.create') }}
</button>
</div> </div>
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500"> <code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
{{ branchPreview }} {{ branchPreview }}

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"
@@ -8,10 +8,10 @@
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''" :error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true" @blur="touched.title = true"
/> />
<MalioInputTextArea <MalioInputRichText
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" min-height="120px"
/> />
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />
@@ -25,34 +25,31 @@
</div> </div>
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'"> <div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button <MalioButton
v-if="canArchive" v-if="canArchive"
type="button" variant="secondary"
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" :label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleArchive" @click="handleArchive"
> />
{{ $t('archive.archiveButton') }} <MalioButton
</button>
<button
v-if="canUnarchive" v-if="canUnarchive"
type="button" variant="secondary"
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" :label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleUnarchive" @click="handleUnarchive"
> />
{{ $t('archive.unarchiveButton') }} <MalioButton
</button> label="Enregistrer"
<button button-class="w-auto px-6"
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" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -0,0 +1,152 @@
<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">
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
variant="ghost"
icon-size="20"
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
/>
<div class="flex items-center gap-1">
<Icon
v-if="task.collaborators?.length"
name="mdi:account-group"
class="h-4 w-4 text-neutral-400"
:title="task.collaborators.map(c => c.username).join(', ')"
/>
<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>
</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,16 +24,16 @@
{{ task.project.code }}-{{ task.number }} {{ task.project.code }}-{{ task.number }}
</span> </span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900"> <h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }} {{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2> </h2>
</div> </div>
<button <MalioButtonIcon
type="button" icon="mdi:close"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600" aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close" @click="close"
> />
<Icon name="mdi:close" size="20" />
</button>
</div> </div>
<!-- Client ticket link --> <!-- Client ticket link -->
@@ -56,6 +56,25 @@
<!-- Body --> <!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6"> <form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6">
<button
v-for="tab in availableTabs"
: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' | 'mails'"
>
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
</button>
</nav>
</div>
<div v-show="activeTab === 'details'">
<!-- Title --> <!-- Title -->
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"
@@ -65,6 +84,20 @@
@blur="touched.title = true" @blur="touched.title = true"
/> />
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects --> <!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect <MalioSelect
@@ -137,12 +170,36 @@
</div> </div>
</div> </div>
<!-- Collaborators -->
<div v-if="collaboratorOptions.length" class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
<div class="flex flex-wrap gap-2">
<label
v-for="user in collaboratorOptions"
:key="user.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.collaboratorIds.includes(user.value)
? 'bg-primary-500 text-white shadow-sm'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="user.value"
:checked="form.collaboratorIds.includes(user.value)"
@change="toggleCollaborator(user.value)"
/>
{{ user.label }}
</label>
</div>
</div>
<!-- Description --> <!-- Description -->
<div class="mt-5"> <div class="mt-5">
<MalioInputTextArea <MalioInputRichText
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" min-height="180px"
/> />
</div> </div>
@@ -182,54 +239,312 @@
v-if="hasBookStack && isEditing && task" v-if="hasBookStack && isEditing && task"
:task-id="task.id" :task-id="task.id"
/> />
</div>
<div v-show="activeTab === 'planning'" class="space-y-6">
<!-- Dates section -->
<div>
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledStart') }}</label>
<input
v-model="form.scheduledStart"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.scheduledEnd') }}</label>
<input
v-model="form.scheduledEnd"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div class="mt-4">
<div class="sm:w-1/2">
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.deadline') }}</label>
<input
v-model="form.deadline"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
</div>
<!-- Calendar sync -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.syncToCalendar"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
</label>
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
<Icon
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
size="18"
/>
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
</span>
</div>
</div>
<!-- Recurrence -->
<div class="rounded-lg border border-neutral-200 p-4">
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.isRecurring"
type="checkbox"
class="rounded border-neutral-300"
/>
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
</label>
<div v-if="form.isRecurring" class="mt-4 space-y-4">
<!-- Type -->
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
</select>
</div>
<!-- Interval -->
<MalioInputText
v-model="form.recurrenceInterval"
:label="$t('tasks.planning.interval')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="100"
/>
<!-- Weekly: days of week -->
<div v-if="form.recurrenceType === 'weekly'">
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="day in weekDays"
:key="day.value"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
:class="form.recurrenceDaysOfWeek.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
>
<input
type="checkbox"
class="hidden"
:value="day.value"
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
@change="toggleDay(day.value)"
/>
{{ day.label }}
</label>
</div>
</div>
<!-- Monthly options -->
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
{{ $t('tasks.planning.dayOfMonth') }}
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
{{ $t('tasks.planning.weekOfMonth') }}
</label>
</div>
<MalioInputText
v-if="form.monthlyMode === 'dayOfMonth'"
v-model="form.recurrenceDayOfMonth"
:label="$t('tasks.planning.dayOfMonthLabel')"
type="number"
input-class="w-full sm:w-1/3"
min="1"
max="31"
/>
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option :value="1">1er</option>
<option :value="2">2ème</option>
<option :value="3">3ème</option>
<option :value="4">4ème</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
</select>
</div>
</div>
</div>
<!-- End of recurrence -->
<div class="space-y-3">
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="never" type="radio" />
{{ $t('tasks.planning.neverEnds') }}
</label>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
{{ $t('tasks.planning.afterOccurrences') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'occurrences'"
v-model="form.recurrenceMaxOccurrences"
type="number"
input-class="w-20"
min="1"
/>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.recurrenceEnd" value="date" type="radio" />
{{ $t('tasks.planning.onDate') }}
</label>
<MalioInputText
v-if="form.recurrenceEnd === 'date'"
v-model="form.recurrenceEndDate"
type="date"
input-class="w-44"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Onglet Mails -->
<div v-show="activeTab === 'mails'" class="space-y-4">
<!-- Chargement -->
<div v-if="mailsLoading" class="flex items-center justify-center py-8">
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
</div>
<!-- Vide -->
<div
v-else-if="linkedMails.length === 0"
class="flex flex-col items-center justify-center gap-3 py-8 text-center"
>
<Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
<p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
</div>
<!-- Liste mails liés -->
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
<NuxtLink
v-for="mail in linkedMails"
:key="mail.id"
:to="`/mail?messageId=${mail.id}`"
class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
:title="$t('mail.taskTab.openInMailer')"
>
<Icon
name="material-symbols:mail-outline"
size="16"
class="mt-0.5 flex-shrink-0 text-neutral-400"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-neutral-800">
{{ mail.subject ?? $t('mail.noSubject') }}
</p>
<p class="flex items-center gap-2 text-xs text-neutral-500">
<span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
<span>·</span>
<span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
</p>
</div>
<Icon
name="material-symbols:open-in-new"
size="14"
class="flex-shrink-0 text-neutral-300"
/>
</NuxtLink>
</div>
<!-- Bouton lier un mail -->
<div class="pt-2">
<MalioButton
:label="$t('mail.taskTab.linkButton')"
variant="secondary"
icon-name="material-symbols:link"
icon-position="left"
:icon-size="14"
button-class="w-auto"
@click="showMailPickerModal = true"
/>
</div>
<!-- Modal picker mail -->
<MailPickerModal
v-if="task"
v-model="showMailPickerModal"
:task-id="task.id"
@linked="handleMailLinked"
/>
</div>
<!-- Footer --> <!-- Footer -->
<div <div
class="mt-6 flex items-center border-t border-neutral-100 pt-5" class="mt-6 flex items-center border-t border-neutral-100 pt-5"
:class="isEditing ? 'justify-between' : 'justify-end'" :class="isEditing ? 'justify-between' : 'justify-end'"
> >
<button <MalioButton
v-if="isEditing" v-if="isEditing"
type="button" variant="danger"
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50" label="Supprimer"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="confirmDeleteOpen = true" @click="confirmDeleteOpen = true"
> />
Supprimer
</button>
<div class="flex gap-3"> <div class="flex gap-3">
<button <MalioButton
v-if="canArchive" v-if="canArchive"
type="button" variant="tertiary"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50" :label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleArchive" @click="handleArchive"
> />
{{ $t('archive.archiveButton') }} <MalioButton
</button>
<button
v-if="canUnarchive" v-if="canUnarchive"
type="button" variant="tertiary"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50" :label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="handleUnarchive" @click="handleUnarchive"
> />
{{ $t('archive.unarchiveButton') }} <MalioButton
</button> variant="tertiary"
<button label="Annuler"
type="button" button-class="w-auto px-4"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@click="close" @click="close"
> />
Annuler <MalioButton
</button> label="Enregistrer"
<button button-class="w-auto px-6"
type="submit"
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</div> </div>
</form> </form>
@@ -265,6 +580,11 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -276,6 +596,7 @@ const props = defineProps<{
tags: TaskTag[] tags: TaskTag[]
groups: TaskGroup[] groups: TaskGroup[]
users: UserData[] users: UserData[]
projects?: Project[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -296,6 +617,14 @@ function close() {
const isEditing = computed(() => !!props.task) const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false) const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
// ─── Onglet Mails ─────────────────────────────────────────────────────────
const mailService = useMailService()
const linkedMails = ref<MailMessageHeaderDto[]>([])
const mailsLoading = ref(false)
const showMailPickerModal = ref(false)
const giteaUrl = ref('') const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService() const { getSettings: getGiteaSettings } = useGiteaService()
@@ -315,13 +644,31 @@ const form = reactive({
effortId: null as number | null, effortId: null as number | null,
priorityId: null as number | null, priorityId: null as number | null,
assigneeId: null as number | null, assigneeId: null as number | null,
collaboratorIds: [] as number[],
groupId: null as number | null, groupId: null as number | null,
tagIds: [] as number[], tagIds: [] as number[],
clientTicketId: null as number | null, clientTicketId: null as number | null,
projectId: null as number | null,
scheduledStart: '',
scheduledEnd: '',
deadline: '',
syncToCalendar: false,
isRecurring: false,
recurrenceType: 'daily' as string,
recurrenceInterval: '1',
recurrenceDaysOfWeek: [] as string[],
recurrenceDayOfMonth: '',
monthlyMode: 'dayOfMonth' as string,
recurrenceWeekOfMonth: 1,
recurrenceWeekDay: 'monday' as string,
recurrenceEnd: 'never' as string,
recurrenceMaxOccurrences: '',
recurrenceEndDate: '',
}) })
const touched = reactive({ const touched = reactive({
title: false, title: false,
project: false,
}) })
const statusOptions = computed(() => const statusOptions = computed(() =>
@@ -340,8 +687,34 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id }))
) )
const groupOptions = computed(() => const collaboratorOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id })) props.users
.filter(u => u.id !== form.assigneeId)
.map(u => ({ label: u.username, value: u.id }))
)
watch(() => form.assigneeId, (newAssigneeId) => {
if (newAssigneeId) {
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
}
})
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(() => { const canArchive = computed(() => {
@@ -364,6 +737,28 @@ function toggleTag(id: number) {
} }
} }
function toggleCollaborator(userId: number) {
const idx = form.collaboratorIds.indexOf(userId)
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
else form.collaboratorIds.push(userId)
}
const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
{ value: 'thursday', label: t('tasks.planning.days.thu') },
{ value: 'friday', label: t('tasks.planning.days.fri') },
{ value: 'saturday', label: t('tasks.planning.days.sat') },
{ value: 'sunday', label: t('tasks.planning.days.sun') },
])
function toggleDay(day: string) {
const idx = form.recurrenceDaysOfWeek.indexOf(day)
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
else form.recurrenceDaysOfWeek.push(day)
}
function populateForm(task: Task | null) { function populateForm(task: Task | null) {
if (task) { if (task) {
form.title = task.title ?? '' form.title = task.title ?? ''
@@ -372,9 +767,46 @@ function populateForm(task: Task | null) {
form.effortId = task.effort?.id ?? null form.effortId = task.effort?.id ?? null
form.priorityId = task.priority?.id ?? null form.priorityId = task.priority?.id ?? null
form.assigneeId = task.assignee?.id ?? null form.assigneeId = task.assignee?.id ?? null
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
form.groupId = task.group?.id ?? null form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id) form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null form.clientTicketId = task.clientTicket?.id ?? null
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
form.syncToCalendar = task.syncToCalendar ?? false
if (task.recurrence) {
form.isRecurring = true
form.recurrenceType = task.recurrence.type
form.recurrenceInterval = String(task.recurrence.interval)
form.recurrenceDaysOfWeek = task.recurrence.daysOfWeek ?? []
form.recurrenceDayOfMonth = task.recurrence.dayOfMonth ? String(task.recurrence.dayOfMonth) : ''
form.recurrenceWeekOfMonth = task.recurrence.weekOfMonth ?? 1
form.monthlyMode = task.recurrence.weekOfMonth ? 'weekOfMonth' : 'dayOfMonth'
form.recurrenceWeekDay = task.recurrence.daysOfWeek?.[0] ?? 'monday'
if (task.recurrence.maxOccurrences) {
form.recurrenceEnd = 'occurrences'
form.recurrenceMaxOccurrences = String(task.recurrence.maxOccurrences)
} else if (task.recurrence.endDate) {
form.recurrenceEnd = 'date'
form.recurrenceEndDate = task.recurrence.endDate.slice(0, 10)
} else {
form.recurrenceEnd = 'never'
}
} else {
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
}
} else { } else {
form.title = '' form.title = ''
form.description = '' form.description = ''
@@ -382,21 +814,46 @@ function populateForm(task: Task | null) {
form.effortId = null form.effortId = null
form.priorityId = null form.priorityId = null
form.assigneeId = null form.assigneeId = null
form.collaboratorIds = []
form.groupId = null form.groupId = null
form.tagIds = [] form.tagIds = []
form.clientTicketId = null form.clientTicketId = null
form.projectId = null
form.scheduledStart = ''
form.scheduledEnd = ''
form.deadline = ''
form.syncToCalendar = false
form.isRecurring = false
form.recurrenceType = 'daily'
form.recurrenceInterval = '1'
form.recurrenceDaysOfWeek = []
form.recurrenceDayOfMonth = ''
form.monthlyMode = 'dayOfMonth'
form.recurrenceWeekOfMonth = 1
form.recurrenceWeekDay = 'monday'
form.recurrenceEnd = 'never'
form.recurrenceMaxOccurrences = ''
form.recurrenceEndDate = ''
} }
touched.title = false touched.title = false
touched.project = false
} }
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
activeTab.value = 'details'
confirmDeleteDocOpen.value = false confirmDeleteDocOpen.value = false
documentToDelete.value = null documentToDelete.value = null
linkedMails.value = []
populateForm(props.task) populateForm(props.task)
try { const pid = resolvedProjectId.value
clientTickets.value = await clientTicketService.getAll({ project: props.projectId }) if (pid) {
} catch { try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = [] clientTickets.value = []
} }
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) { if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
@@ -419,6 +876,7 @@ watch(() => props.task, (task) => {
const { create, update, remove } = useTaskService() const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService() const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService() const clientTicketService = useClientTicketService()
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
const { t } = useI18n() const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([]) const clientTickets = ref<ClientTicket[]>([])
@@ -426,9 +884,68 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id })) clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
) )
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore() const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isClientOnly = computed(() =>
authStore.user?.roles?.includes('ROLE_CLIENT') === true
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
)
const isMailUser = computed(() => !isClientOnly.value)
const availableTabs = computed(() => {
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
if (isEditing.value && isMailUser.value) base.push('mails')
return base
})
async function loadLinkedMails(): Promise<void> {
if (!props.task || !isMailUser.value) return
mailsLoading.value = true
try {
linkedMails.value = await mailService.listMailsForTask(props.task.id)
} catch {
linkedMails.value = []
} finally {
mailsLoading.value = false
}
}
watch(activeTab, async (tab) => {
if (tab === 'mails' && props.task) {
await loadLinkedMails()
}
})
async function handleMailLinked(): Promise<void> {
showMailPickerModal.value = false
await loadLinkedMails()
}
function formatMailDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', {
day: '2-digit',
month: 'short',
})
}
function ticketStatusClass(status: string): string { function ticketStatusClass(status: string): string {
switch (status) { switch (status) {
case 'new': return 'bg-blue-100 text-blue-700' case 'new': return 'bg-blue-100 text-blue-700'
@@ -512,7 +1029,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) { if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string' const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task ? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}` : (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) { if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop() await timerStore.stop()
} }
@@ -541,7 +1058,9 @@ async function handleUnarchive() {
async function handleSubmit() { async function handleSubmit() {
touched.title = true touched.title = true
touched.project = true
if (!form.title.trim()) return if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -552,16 +1071,47 @@ async function handleSubmit() {
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null, effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null, priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null, assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
group: form.groupId ? `/api/task_groups/${form.groupId}` : null, group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`, project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`), tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null, clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
scheduledStart: form.scheduledStart || null,
scheduledEnd: form.scheduledEnd || null,
deadline: form.deadline || null,
syncToCalendar: form.syncToCalendar,
} }
let savedTask: Task
if (isEditing.value && props.task) { if (isEditing.value && props.task) {
await update(props.task.id, payload) savedTask = await update(props.task.id, payload)
} else { } else {
await create(payload) savedTask = await create(payload)
}
// Handle recurrence
if (form.isRecurring) {
const recPayload = {
type: form.recurrenceType as 'daily' | 'weekly' | 'monthly' | 'yearly',
interval: parseInt(form.recurrenceInterval) || 1,
daysOfWeek: form.recurrenceType === 'weekly' ? form.recurrenceDaysOfWeek : null,
dayOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'dayOfMonth'
? parseInt(form.recurrenceDayOfMonth) || null : null,
weekOfMonth: form.recurrenceType === 'monthly' && form.monthlyMode === 'weekOfMonth'
? form.recurrenceWeekOfMonth : null,
endDate: form.recurrenceEnd === 'date' ? form.recurrenceEndDate || null : null,
maxOccurrences: form.recurrenceEnd === 'occurrences'
? parseInt(form.recurrenceMaxOccurrences) || null : null,
}
if (savedTask.recurrence) {
await updateRecurrence(savedTask.recurrence.id, recPayload)
} else {
const recurrence = await createRecurrence(recPayload)
await update(savedTask.id, { recurrence: recurrence['@id'] ?? `/api/task_recurrences/${recurrence.id}` })
}
} else if (isEditing.value && props.task?.recurrence) {
await removeRecurrence(props.task.recurrence.id)
} }
emit('saved') emit('saved')

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -13,16 +13,15 @@
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <MalioButton
type="submit" label="Enregistrer"
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" button-class="w-auto px-6"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,123 +0,0 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
label="Libellé"
input-class="w-full"
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
@blur="touched.label = true"
/>
<MalioInputText
v-model="form.position"
label="Position"
input-class="w-full"
type="number"
/>
<div class="mt-4">
<ColorPicker v-model="form.color" />
</div>
<div class="mt-4 flex items-center gap-2">
<input
id="isFinal"
v-model="form.isFinal"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isFinal" class="text-sm font-medium text-neutral-700">
{{ $t('archive.statusFinal') }}
</label>
</div>
<div class="mt-6 flex justify-end">
<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>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
import { useTaskStatusService } from '~/services/task-statuses'
const props = defineProps<{
modelValue: boolean
item: TaskStatus | null
}>()
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.item)
const isSubmitting = ref(false)
const form = reactive({
label: '',
position: '0',
color: '#222783',
isFinal: false,
})
const touched = reactive({
label: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.label = props.item.label ?? ''
form.position = String(props.item.position ?? 0)
form.color = props.item.color ?? '#222783'
form.isFinal = props.item.isFinal ?? false
} else {
form.label = ''
form.position = '0'
form.color = '#222783'
form.isFinal = false
}
touched.label = false
}
})
const { create, update } = useTaskStatusService()
async function handleSubmit() {
touched.label = true
if (!form.label.trim()) return
isSubmitting.value = true
try {
const payload: TaskStatusWrite = {
label: form.label.trim(),
position: Number(form.position),
color: form.color,
isFinal: form.isFinal,
}
if (isEditing.value && props.item) {
await update(props.item.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -13,16 +13,15 @@
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <MalioButton
type="submit" label="Enregistrer"
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" button-class="w-auto px-6"
:disabled="isSubmitting" :disabled="isSubmitting"
> @click="handleSubmit"
Enregistrer />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit"> <form class="space-y-4" @submit.prevent="onSubmit">
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -11,14 +11,11 @@
/> />
</div> </div>
<div> <MalioInputRichText
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label> v-model="form.description"
<textarea label="Description"
v-model="form.description" min-height="120px"
rows="3" />
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
@@ -97,27 +94,34 @@
</div> </div>
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'"> <div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button <MalioButton
v-if="isEditing" v-if="isEditing"
type="button" variant="danger"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition" label="Supprimer"
button-class="w-auto px-4"
@click="onDelete" @click="onDelete"
> />
Supprimer <div class="flex gap-2">
</button> <MalioButton
<button v-if="isEditing"
type="submit" variant="secondary"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition" label="Dupliquer"
> button-class="w-auto px-4"
Enregistrer @click="onDuplicate"
</button> />
<MalioButton
label="Enregistrer"
button-class="w-auto px-4"
@click="onSubmit"
/>
</div>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
@@ -231,6 +235,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
} }
}) })
async function onDuplicate() {
if (!form.date || !form.startTime || !form.endTime) return
const { create } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISO(form.date, form.startTime),
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
await create(payload as TimeEntryWrite)
emit('saved')
isOpen.value = false
}
async function onDelete() { async function onDelete() {
if (!props.entry) return if (!props.entry) return
const { remove } = useTimeEntryService() const { remove } = useTimeEntryService()
@@ -257,7 +281,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) { if (isEditing.value && props.entry) {
await update(props.entry.id, payload) await update(props.entry.id, payload)
} else { } else {
await create(payload as any) await create(payload as TimeEntryWrite)
} }
emit('saved') emit('saved')

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