Compare commits

..

142 Commits

Author SHA1 Message Date
Matthieu
65df36dd1a fix(absences) : garde-fou solde négatif à l'approbation + cohérence fixture
- AbsenceBalanceService::availableForRequest() : jours disponibles (acquis N-1
  + en cours N − pris) pour la période de la demande, null si type non suivi.
- Blocage de l'approbation si countedDays > disponible, dans les deux chemins
  (REST AbsenceReviewProcessor + MCP ReviewAbsenceRequestTool), comme le motif
  décès. Les CP en cours d'acquisition restent posables, mais pas au-delà du
  droit total (plus de solde négatif silencieux à l'approbation).
- Fixture : demande pending CP d'alice replacée dans sa période de référence
  2025-2026 (26→29/05/2026, 4 j ouvrés) et solde pending aligné (5 → 4) ;
  plus de "en attente" orphelin non lié à une demande.
- Test fonctionnel testApproveBeyondAvailableBalanceIsBlocked + employé de test
  doté d'un droit pour que les approbations existantes passent le garde-fou.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:49 +02:00
Matthieu
f9773b3a5e feat(absences) : mise en conformité légale (événements familiaux, demi-journée, CCN)
Périmètre 1-6 du design 2026-05-22-absence-legal-compliance-fixes (points
lourds — ancienneté, CP pendant maladie, rétention — reportés en backlog).

- Événements familiaux sans solde : AbsenceType::decrementsBalance() ne vaut
  true que pour les CP. Mariage/PACS, naissance, décès = droits par événement ;
  congé parental = suspension ; maladie = Sécu. Plus de solde fantôme.
- Décès : daysPerEvent = null (selon lien de parenté) + motif obligatoire à la
  création (REST + MCP), les minimums légaux étant rappelés dans l'aide.
- Ajout du congé naissance (type, policy 3 j, justificatif, libellés/couleur front).
- Garde-fou demi-journée : -0,5 appliqué uniquement si le jour-borne est
  réellement décompté (corrige un sous-décompte week-end/férié) — TDD.
- CCN documentée : paramètre app.absence.convention = "Syntec (IDCC 1486)",
  rappelée en sous-titre admin et dans l'aide /help.

Tests : AbsenceDayCalculatorTest (garde-fou demi-journée), AbsenceRequestLifecycle
(motif décès obligatoire + aucun solde touché). make test 52/52, build Nuxt OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:00:28 +02:00
Matthieu
e9aaccc62c docs(absences) : spec de mise en conformité légale (périmètre 1-6)
Design des corrections légales retenues (modèle événements familiaux sans
solde, décès=motif obligatoire, ajout naissance, parental=suspension,
garde-fou demi-journée, CCN documentée). Points lourds (ancienneté, CP
pendant maladie, rétention) explicitement reportés en backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:20:20 +02:00
Matthieu
4858f73d07 docs(absences) : aide /help du module + revue de conformité légale
- frontend/content/help/06-absences.md : nouveau chapitre d'aide « Absences »
  (poser une demande, lecture des soldes, mode de décompte des CP, ajout d'un
  salarié — nouveau ou déjà présent via le solde initial). Enregistré dans help.vue.
- revue de conformité (Syntec/RGPD) du module absences : rapport d'audit avec
  constats légaux et RGPD + recommandations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:29:01 +02:00
Matthieu
11fdf8d1bf fix(absences) : durcissement RGPD des données RH des utilisateurs
Suite à la revue de conformité du module absences.

Fuite corrigée : GET /api/users et /api/users/{id} n'avaient aucun contrôle
d'accès alors que le groupe user:list exposait les données RH/familiales
(date d'embauche, contrat, soldes de CP, rôles…). Tout utilisateur authentifié
pouvait donc lire ces informations sur tous ses collègues.
- chaque champ RH (isEmployee, hireDate, endDate, contractType, workTimeRatio,
  annualLeaveDays, referencePeriodStart, initialLeaveBalance) ainsi que roles
  est désormais exposé via #[ApiProperty(security: "is_granted('ROLE_ADMIN') or
  object == user")] : visible uniquement par un admin ou par l'utilisateur
  lui-même. id et username restent publics (sélecteurs d'assigné, avatars).

Minimisation : suppression de familySituation et nbChildren, collectés et
exposés (form RH, API, outil MCP) mais utilisés par aucun calcul.
- entité User + enum FamilySituation + migration de drop des colonnes
- Serializer MCP, update-user (MCP), EmployeeDrawer, DTO, fixtures, i18n

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:28:48 +02:00
Matthieu
2b148fa65a feat(absences) : outils MCP CRUD pour les absences
Expose le module Absences via le serveur MCP et comble les trous CRUD
existants (projets, groupes, métadonnées de tâches, clients, users RH).

Absences (réutilise AbsenceDayCalculator + AbsenceBalanceService pour ne
pas contourner la logique de soldes) :
- list/get/create/review/cancel/delete-absence-request
- list/update-absence-policy, list/update-absence-balance
- create-absence-request prend un userId explicite (agir au nom d'un employé) ;
  review/cancel maintiennent les soldes (pending/taken) cohérents
- AbsenceRequestRepository::findFiltered pour les filtres de liste

Trous CRUD comblés :
- delete-project, delete-group
- CRUD tag, effort, priority
- CRUD status (couplé au workflow, avec category)
- CRUD client, get/update-user (champs RH, sans password ni roles)

Sérialisation centralisée (Serializer::absenceRequest/Policy/Balance/client/userFull).
Instructions MCP (mcp.yaml) mises à jour : statuts par workflow + domaine absences.

Tests : tests/Functional/Mcp/AbsenceRequestLifecycleTest (création / approbation /
annulation admin) vérifient le cycle complet et la cohérence des soldes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:56 +02:00
Matthieu
2a0b202d32 feat(absences) : avancement module absences + suppression du portail client
Deux lots regroupés sur la branche feat/absence-management.

Suppression complète du portail client :
- retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER
- supprime l'entité ClientTicket (+ repo, states, relations), User.client et
  User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc
  ROLE_CLIENT de MailAccessChecker
- front : pages /portal, layout portal, composants client-ticket/,
  AdminClientTicketTab, services/dto/i18n/docs associés
- fixtures : retire les users client-liot / client-acme
- migration Version20260522110000 (drop client_ticket, user_allowed_projects,
  colonnes liées ; task_document.task_id -> NOT NULL)
- tests : retire les cas obsolètes testant le blocage des clients sur le mail

Module gestion des absences (WIP) :
- entités / migrations (Version20260521160000, Version20260522090000)
- pages absences.vue / team-absences.vue, composants frontend/components/absence/
- services front, AccrueLeaveCommand, PublicHolidayController

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:31:31 +02:00
Matthieu
de98924fd3 feat(absences) : fondation backend du module de gestion des absences
Module type Payfit (étapes 1+2 de la spec V1) : demande d'absence, validation
admin, soldes à jour.

- Enums : AbsenceType, AbsenceStatus, HalfDay, ContractType, FamilySituation
- Entités : AbsencePolicy, AbsenceBalance, AbsenceRequest + champs RH sur User
- Services : PublicHolidayProvider (fériés FR métropole en PHP pur, Computus),
  AbsenceDayCalculator (décompte jours ouvrés/ouvrables + demi-journées, TDD),
  AbsenceBalanceService (périodes + pending/taken/recrédit)
- API Platform : providers/processors (création, approve/reject/cancel) + RBAC
  me/admin, contrôleurs preview (dry-run), upload/download justificatif, calendrier
- Migrations : une par table + colonnes RH user (DEFAULT puis DROP DEFAULT)
- Fixtures : 5 policies par défaut, salariés démo, soldes et demandes
- Tests unitaires : PublicHolidayProvider, AbsenceDayCalculator (12 tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:45:14 +02:00
gitea-actions
325a7b07f9 chore: bump version to v0.4.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-21 09:42:51 +00:00
Matthieu
bcbc04325e feat(mail) : décodage des en-têtes MIME + aperçu inline des pièces jointes
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Décode les encoded-words MIME (RFC 2047) des sujets et noms d'expéditeur
  via App\Mail\MimeHeaderDecoder, appliqué dans ImapMailProvider (sync propre)
- Commande app:mail:redecode-headers (--dry-run) pour re-décoder l'existant en base
- Aperçu inline images + PDF en visionneuse modale plein écran (MailAttachmentPreview),
  téléchargement conservé pour les autres types
- Tests unitaires du décodeur + maj docs/mail-integration.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:42:38 +02:00
gitea-actions
8f2a688740 chore: bump version to v0.4.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 48s
2026-05-21 09:19:01 +00:00
Matthieu
6491943930 docs : ajoute la section Messagerie au centre d'aide + maj admin/intégrations (mail OVH)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:18:42 +02:00
gitea-actions
a9f05fd819 chore: bump version to v0.4.5
All checks were successful
Build & Push Docker Image / build (push) Successful in 53s
Auto Tag Develop / tag (push) Successful in 6s
2026-05-21 08:57:40 +00:00
Matthieu
925be5d181 fix(ui) : sélecteur de statut Malio dans le drawer de création de workflow
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Remplace le <select> natif du navigateur par MalioSelect (catégorie de statut).
MalioSelect accepte les valeurs string (enum StatusCategory), contrairement à ce
qu'indiquait la note CLAUDE.md — note corrigée en conséquence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:53:19 +02:00
Matthieu
5da165f739 docs : corrige le déploiement prod (Docker) et documente les variables d'env mail
- README : section Variables d'environnement (ENCRYPTION_KEY, LOCK_DSN) + section Déploiement passée au flow Docker (deploy.sh)
- mail-cron-setup : sépare dev (make, php-lesstime-fpm) et prod (lesstime-app, docker compose exec), cron prod réel
- infra/prod/.env.example : ajoute ENCRYPTION_KEY et LOCK_DSN (manquaient, requis pour la sync mail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:48:55 +02:00
gitea-actions
2bffff9b83 chore: bump version to v0.4.4
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 57s
2026-05-21 08:48:41 +00:00
d7af8ee138 Correctifs UI workflow — specs + implémentation (8 chantiers) (#6)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Suite à l'arrivée des workflows, correction des régressions UI et améliorations UX mail/modales (reviews Lucile Schnödt, Tristan Schnödtin).

**Specs & décisions :** `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`
**Plan d'implémentation :** `docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md`

Cette PR contient désormais **les specs ET l'implémentation complète**.

## Chantiers livrés

| # | Chantier | Détail |
|---|----------|--------|
| 2 | Sélecteur de statut filtré par workflow | `statusOptions` dérivé de `project.workflow.statuses`, statut courant conservé s'il est hors workflow |
| 1 | Drag & drop « Mes tâches » | handlers `@dragover/@drop` ; résolution par workflow/catégorie (0→refus, 1→PATCH, ≥2→popover `StatusPickerPopover`) |
| 4 | Couleurs | (a) migration Doctrine remettant les hex classiques sur le workflow Standard ; (b) entêtes kanban teintées via `STATUS_CATEGORY_COLOR` + contraste auto ; (c) couleur par défaut par catégorie dans `WorkflowDrawer` |
| 5 | Suppression du bouton « Lier un mail » | + retrait de `MailPickerModal` et i18n associée |
| 6 | Création de tâche depuis un mail | back : `assigneeId` + `statusId` (défaut = 1er statut du workflow), priorité retirée (TDD) ; front : `MailCreateTaskModal` sur `AppModal` + sélecteurs user/statut |
| 7 | Modale réutilisable | nouveau `components/ui/AppModal.vue` (footer sticky) ; footer de `TaskModal` sorti du form scrollable |
| 3 | Cartes responsive | badges en `flex-wrap` pleine taille (plus aucun débordement) |
| 8 | (dette) Sélecteur de catégorie en `MalioSelect` | la lib supporte les valeurs `string` ; note CLAUDE.md corrigée |

## Vérifications
- Build frontend OK ; PHPUnit **34 tests verts** (nouveau test fonctionnel TDD sur `create-task`).
- Vérif navigateur (Chrome MCP) sur **données prod importées en local** : #2, #3, #4, #5, #6, #7 confirmés.
- Revue de code finale : **APPROVED_WITH_NITS**.

## À noter
- ⚠️ **#1 (D&D)** : le drag & drop HTML5 natif n'est pas auto-testable → **test manuel requis**.
- 🗄️ **#4 (migration)** : `migrations/Version20260521094948.php` s'appliquera en **prod au prochain `make migration-migrate`**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #6
2026-05-21 08:48:31 +00:00
gitea-actions
eb2adc9fdc chore: bump version to v0.4.3
All checks were successful
Build & Push Docker Image / build (push) Successful in 53s
Auto Tag Develop / tag (push) Successful in 7s
2026-05-20 08:22:48 +00:00
Matthieu
4775cbf184 feat(ui) : palette de couleurs élargie + couleur personnalisée, fix champ code projet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
ColorPicker : passe de 9 à 18 teintes prédéfinies (les 9 historiques
conservées en tête pour ne pas désassocier les couleurs existantes) et
ajoute une pastille « couleur personnalisée » (input natif type=color)
permettant n'importe quel hex. Partagé, donc bénéficie aussi aux tags,
priorités, groupes et workflows.

fix(project) : le champ code restait en minuscules. Le @input mutait
form.code à partir de l'ancienne valeur, puis l'émission update:modelValue
de MalioInputText l'écrasait avec la saisie brute → form.code en
minuscules (affiché en majuscules via CSS) → /^[A-Z]{2,10}$/ en échec →
création bloquée. Remplacé par un computed setter (source unique de
vérité : majuscules + lettres uniquement + max 10) et maxLength sur le
champ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:22:38 +02:00
gitea-actions
8be96bce0c chore: bump version to v0.4.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 27s
2026-05-20 07:56:32 +00:00
Matthieu
fb97b8d4e3 fix(mail) : sync à la demande synchrone pour le bouton rafraîchir
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Route MailSyncRequested vers le transport sync au lieu d'async : la
synchro IMAP s'exécute pendant la requête HTTP du clic « rafraîchir »,
donc le re-fetch du front voit immédiatement les nouveaux mails, sans
worker messenger:consume à maintenir en prod. La sync de fond reste
assurée par le cron OS (app:mail:sync, synchrone, indépendant du bus).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:23 +02:00
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
306 changed files with 39000 additions and 4154 deletions

14
.env
View File

@@ -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=change_me_in_env_local 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 ###

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@
###> docker local ### ###> docker local ###
infra/dev/.env.docker.local infra/dev/.env.docker.local
###< docker local ### ###< docker local ###
###> local db dumps ###
*.sql.gz
*.sql.gz:Zone.Identifier
REVIEW.md
###< local db dumps ###

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,10 @@ 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, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration) src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection) src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
src/Enum/ # PHP enums (RecurrenceType) src/Enum/ # PHP enums (RecurrenceType)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler) src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator) 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/)
@@ -29,12 +31,12 @@ migrations/ # Migrations Doctrine
docs/plans/ # Plans d'implémentation docs/plans/ # Plans d'implémentation
docs/superpowers/ # Plans et specs superpowers docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4 frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, my-tasks, 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)
frontend/layouts/ # Layouts (default, portal) frontend/layouts/ # Layouts (default)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService) frontend/composables/# Composables (useApi, useAppVersion, useNotifications, 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, zimbra, task-recurrences) frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, 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/)
``` ```
@@ -83,13 +85,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`) - Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check` - Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
- PHP CS Fixer : règles Symfony + PSR-12 + strict types - PHP CS Fixer : règles Symfony + PSR-12 + strict types
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml` - Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml`
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation) - `User::getRoles()` ajoute toujours `ROLE_USER`
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL - PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}` - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime` - Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique - Endpoints ouverts à tout utilisateur authentifié : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
### Frontend ### Frontend
@@ -99,9 +101,11 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Middleware global `auth.global.ts` protège les routes - Middleware global `auth.global.ts` protège les routes
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
- 4 espaces d'indentation - 4 espaces d'indentation
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string - MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
- 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 ### 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
@@ -132,7 +136,15 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- User admin : `admin` / `admin` (ROLE_ADMIN) - User admin : `admin` / `admin` (ROLE_ADMIN)
- 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)
- 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 - 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) - 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.

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)
@@ -44,6 +45,10 @@ make install
L'application est accessible sur **http://localhost:8082**. L'application est accessible sur **http://localhost:8082**.
Les valeurs par défaut du `.env` committé suffisent pour démarrer en local. Pour la prod
(et pour activer la messagerie), surcharger les variables sensibles dans `.env.local`
voir « Variables d'environnement » ci-dessous.
### Comptes de test (fixtures) ### Comptes de test (fixtures)
| Utilisateur | Mot de passe | Rôle | Détails | | Utilisateur | Mot de passe | Rôle | Détails |
@@ -55,6 +60,25 @@ L'application est accessible sur **http://localhost:8082**.
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) | | `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) | | `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
## Variables d'environnement
Les variables sont définies dans `.env` (committé, valeurs par défaut pour le dev) et
peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles vont dans le
`.env` du serveur (`/var/www/lesstime/.env`, voir `infra/prod/.env.example`).
| Variable | Rôle | Défaut dev | À fixer en prod |
|----------|------|-----------|-----------------|
| `APP_SECRET` | Secret Symfony | placeholder | ✅ (hex 32) |
| `JWT_PASSPHRASE` | Passphrase des clés JWT | placeholder | ✅ |
| `DATABASE_URL` | Connexion PostgreSQL | container `db` | ✅ (`host.docker.internal`) |
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
> Générer une clé : `php -r "echo bin2hex(random_bytes(32));"`.
## Commandes ## Commandes
### Docker ### Docker
@@ -73,6 +97,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
@@ -216,13 +241,19 @@ docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token
## Déploiement ## Déploiement
1. Déployer le code sur le serveur La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
2. `composer install --no-dev --optimize-autoloader` (`gitea.malio.fr/malio-dev/lesstime:<tag>`), puis déployée par le script `deploy.sh` sur
3. `php bin/console doctrine:migrations:migrate --no-interaction` le serveur (dossier `/var/www/lesstime`, container `lesstime-app`).
4. `php bin/console cache:clear --env=prod`
5. `cd frontend && npm install && npm run build:dist` ```bash
6. `docker restart nginx-lesstime` # Sur le serveur, depuis /var/www/lesstime
7. Ouvrir le port 8082 sur le firewall (LAN uniquement) sudo ./deploy.sh # déploie la dernière image (latest)
sudo ./deploy.sh v0.4.2 # déploie une version précise
```
Le script active la maintenance, pull l'image, redémarre le container, lance les migrations
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
**`doc/deployment-docker.md`**.
## Licence ## Licence

View File

@@ -21,12 +21,15 @@
"sabre/vobject": "^4.5", "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/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
@@ -36,7 +39,8 @@
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "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": {
@@ -93,6 +97,8 @@
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94", "friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0" "phpunit/phpunit": "^13.0",
"symfony/browser-kit": "^8.0",
"symfony/css-selector": "^8.0"
} }
} }

1343
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,8 +6,14 @@ mcp:
This server provides access to the Lesstime project management system. This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries. You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). Priorities, efforts, and tags are GLOBAL (shared across all projects).
Statuses belong to a WORKFLOW (not global) — use list-workflows and list-statuses
to see how they group; create-status requires a workflowId and a category.
Groups are PER-PROJECT (each group belongs to one project). Groups are PER-PROJECT (each group belongs to one project).
Absences: manage employee leave with absence-request tools (list/get/create/review/
cancel/delete), absence-policy tools (list/update) and absence-balance tools
(list/update). create-absence-request takes an explicit userId to act on behalf of
an employee; review/cancel keep the leave balances consistent (pending/taken).
Time entries track work duration and can be linked to projects and tasks. Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks. available metadata before creating or updating tasks.
@@ -21,3 +27,6 @@ mcp:
store: file store: file
directory: '%kernel.project_dir%/var/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,33 @@
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:
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
'App\Message\MailSyncRequested': sync
when@test:
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'

View File

@@ -1,6 +1,6 @@
security: security:
role_hierarchy: role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT] ROLE_ADMIN: [ROLE_USER]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
@@ -64,6 +64,8 @@ security:
- { 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: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
# Mail : requiert authentification (le check ROLE_USER est 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

@@ -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>,
* }>, * }>,
@@ -1360,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

View File

@@ -9,6 +9,10 @@
parameters: parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs'
# Reference collective agreement for the absence module's legal defaults.
# To confirm against the company's APE/NAF code (a CCN is not derived from activity alone).
app.absence.convention: 'Syntec (IDCC 1486)'
imports: imports:
- { resource: version.yaml } - { resource: version.yaml }
@@ -44,3 +48,11 @@ services:
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Controller\Absence\AbsenceJustificationUploadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Controller\Absence\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.3.28' app.version: '0.4.7'

View File

@@ -41,7 +41,7 @@ services:
- "8082:80" - "8082:80"
volumes: volumes:
- ./:/var/www/html:ro - ./:/var/www/html:ro
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/lesstime.conf: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

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

@@ -0,0 +1,128 @@
# 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.
> **Dev vs prod** — en dev le container s'appelle `php-lesstime-fpm` et on passe par `make`.
> En **production** le container s'appelle `lesstime-app` (service `app` du `docker-compose.yml`
> dans `/var/www/lesstime`), il n'y a **pas de `make`** : tout passe par `docker compose` / `docker exec`.
## Prérequis
- `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
- `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
- **dev** : `infra/dev/.env.docker.local`
- **prod** : `/var/www/lesstime/.env`
- Container démarré :
- **dev** : `make start` (container `php-lesstime-fpm`)
- **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
## 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é `ENCRYPTION_KEY` doit être **identique** à celle utilisée pour chiffrer le password
lors de la configuration depuis l'admin. Si elle change, les credentials stockés deviennent illisibles.
---
## Dev
### Lancer une sync à la main
```bash
make mail-sync # sync complète (toutes les boîtes)
make mail-sync FOLDER=INBOX # un seul dossier (doit déjà exister en base)
make mail-sync DRYRUN=1 # simulation (dry-run, pas d'écriture BDD)
```
Ou directement dans le container :
```bash
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 (dev)
```bash
make logs-dev # tail -f var/log/dev.log
```
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
---
## Production
En prod, l'app tourne dans le container `lesstime-app` déployé par `sudo ./deploy.sh`
(dossier `/var/www/lesstime`). La commande s'exécute en tant que `www-data` (uid 33),
comme les migrations lancées par `deploy.sh`.
### Lancer une sync à la main
Depuis `/var/www/lesstime` :
```bash
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --folder=INBOX
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --dry-run
```
### Installer le cron
Sur la **machine hôte** (pas dans le container). Comme `docker` requiert `sudo` en prod,
installer le cron sous root :
```bash
sudo crontab -e
```
Ajouter :
```cron
*/10 * * * * cd /var/www/lesstime && docker compose exec -T -u www-data app php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
```
> Le crontab de root exécute déjà les commandes en root → pas de `sudo` à l'intérieur de la ligne cron.
> La commande est **idempotente** (UIDs uniques en base) : la relancer ne duplique pas les données.
### Logs (prod)
```bash
cd /var/www/lesstime
docker compose logs -f --tail=100 app # logs container
docker compose exec app cat var/log/prod.log # log Symfony (volume lesstime_logs)
```
### Checklist setup production
1. [ ] Définir `ENCRYPTION_KEY` (hex 32 bytes) et `LOCK_DSN=flock` dans `/var/www/lesstime/.env`
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. [ ] Lancer une sync manuelle pour valider (commande ci-dessus)
7. [ ] Installer le cron OS (voir « Installer le cron »)
8. [ ] Vérifier les logs après la première sync (`docker compose logs -f app`, chercher `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`)
- 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`
- Les corps de mails sont sanitisés via DOMPurify avant affichage (`frontend/utils/sanitizeMailHtml.ts`)
- Les pixels de tracking distants sont remplacés par un placeholder

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

@@ -0,0 +1,150 @@
# 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)
> docker exec -i php-lesstime-fpm php bin/console app:mail:redecode-headers [--dry-run] # re-décode les en-têtes MIME déjà en base (backfill)
> 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)
- Décodage des en-têtes MIME encodés (RFC 2047, ex `=?UTF-8?Q?...`) sur sujet + nom d'expéditeur (`App\Mail\MimeHeaderDecoder`, appliqué dans `ImapMailProvider`)
- Aperçu inline des pièces jointes images + PDF (visionneuse modale plein écran), téléchargement pour les autres types
- 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, MailAttachmentPreview (visionneuse modale image/PDF)
- `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) |

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,988 @@
# Correctifs UI workflow — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommandé) ou superpowers:executing-plans pour exécuter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`).
**Goal:** Corriger les régressions UI introduites par les workflows (D&D, sélecteur de statut, cartes, couleurs) et améliorer l'UX mail/modales, sur la base de `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`.
**Architecture:** Une brique partagée (filtrage des statuts par workflow + palette de catégories + composant modale réutilisable) consommée par les autres chantiers. Backend modifié uniquement pour l'endpoint `create-task` (#6). Correction de données prod (#4) via migration Doctrine.
**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine (backend, PHPUnit) ; Nuxt 4 / Vue 3 / Pinia / Tailwind / `@malio/layer-ui` (frontend).
> **Note testing (importante).** Lesstime **n'a pas de test runner frontend** (vérifié : pas de vitest/jest dans `frontend/package.json`). La discipline TDD ne s'applique donc qu'au **backend** (PHPUnit via `make test`). Pour le **frontend**, chaque tâche se vérifie par : (1) `npm run build:dist` qui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP sur `http://localhost:8082` (DOM/visuel). **Toujours hard-reload sans cache** après build (le navigateur cache les chunks JS hashés). Login dev avec données prod importées : `Matthieu` / `admin`.
> **Branche.** Créer une branche d'implémentation depuis `develop` (ex. `fix/workflow-ui-fixes`) avant de commencer. Commits fréquents, format `<type>(<scope>) : <message>`.
---
## Ordre d'exécution (dépendances)
1. **Task 1** — Brique front : palette de catégories + helper contraste (`#4b`, réutilisé par #1)
2. **Task 2** — Composant `AppModal` réutilisable (`#7`)
3. **Task 3** — Filtrage du sélecteur de statut par workflow dans TaskModal (`#2`)
4. **Task 4** — Drag & drop dans « Mes tâches » + entêtes teintées (`#1` + `#4b`)
5. **Task 5** — Backend : endpoint `create-task` (statut + assigné, sans priorité) (`#6` back)
6. **Task 6** — Frontend : modale de création depuis mail (`#6` front, sur AppModal)
7. **Task 7** — Suppression du bouton « Lier un mail » (`#5`)
8. **Task 8** — Cartes responsive (`#3`)
9. **Task 9** — Couleurs par défaut par catégorie + migration data prod (`#4a` + `#4c`)
10. **Task 10** — Migration de TaskModal vers AppModal (`#7`)
---
## Task 1 : Palette de catégories + helper de contraste
**Files:**
- Modify: `frontend/services/dto/workflow.ts`
- [ ] **Step 1 : Ajouter la palette et le helper de contraste**
Dans `frontend/services/dto/workflow.ts`, après `STATUS_CATEGORY_LABEL` (l.5-11), ajouter :
```ts
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
todo: '#222783',
in_progress: '#4A90D9',
blocked: '#C62828',
review: '#FF8F00',
done: '#26A69A',
}
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
export function contrastText(hex: string): string {
const c = hex.replace('#', '')
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return lum > 0.6 ? '#1f2937' : '#ffffff'
}
```
- [ ] **Step 2 : Vérifier le build**
Run: `cd frontend && npm run build:dist`
Expected: exit 0, aucune erreur TypeScript.
- [ ] **Step 3 : Commit**
```bash
git add frontend/services/dto/workflow.ts
git commit -m "feat(workflow) : palette de catégories canonique + helper de contraste"
```
---
## Task 2 : Composant modale réutilisable `AppModal` (#7)
**Files:**
- Create: `frontend/components/ui/AppModal.vue`
- [ ] **Step 1 : Créer `AppModal.vue`**
```vue
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
/** Largeur max du panneau */
width?: 'sm' | 'md' | 'lg' | 'xl'
}>(), {
title: '',
width: 'md',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
function close(): void {
emit('update:modelValue', false)
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="app-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 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
:class="WIDTH_CLASS[width]"
>
<!-- Header (fixe) -->
<div class="flex shrink-0 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">
<slot name="title">{{ title }}</slot>
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Body (scrollable) -->
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<slot />
</div>
<!-- Footer (sticky) -->
<div
v-if="$slots.footer"
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-modal-enter-active,
.app-modal-leave-active {
transition: opacity 0.2s ease;
}
.app-modal-enter-active > div:last-child,
.app-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.app-modal-enter-from,
.app-modal-leave-to {
opacity: 0;
}
.app-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>
```
- [ ] **Step 2 : Build**
Run: `cd frontend && npm run build:dist`
Expected: exit 0.
- [ ] **Step 3 : Commit**
```bash
git add frontend/components/ui/AppModal.vue
git commit -m "feat(ui) : composant AppModal réutilisable (header fixe / body scrollable / footer sticky)"
```
> AppModal sera consommé par MailCreateTaskModal (Task 6) et TaskModal (Task 10).
---
## Task 3 : Filtrer le sélecteur de statut par workflow dans TaskModal (#2)
**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (statusOptions ~l.674-676)
**Contexte vérifié :** TaskModal reçoit déjà `:projects` (`Project[]` avec `workflow.statuses`). Le projet effectif est `showProjectSelect ? form.projectId : props.projectId` (cf. l.717). `props.statuses` (global) devient un fallback.
- [ ] **Step 1 : Remplacer `statusOptions`**
Remplacer (l.674-676) :
```ts
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
```
par :
```ts
const effectiveProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId,
)
const statusOptions = computed(() => {
const project = props.projects?.find(p => p.id === effectiveProjectId.value)
const wfStatuses = project?.workflow?.statuses ?? props.statuses
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
// Garder le statut courant s'il n'appartient pas (plus) au workflow, pour ne pas le perdre.
const current = props.task?.status
if (current && !wfStatuses.some(s => s.id === current.id)) {
opts.unshift({ label: current.label, value: current.id })
}
return opts
})
```
> Si une variable `effectiveProjectId`/`activeProjectId` existe déjà (vérifier autour de l.717), réutiliser celle-ci au lieu d'en redéclarer une.
- [ ] **Step 2 : Build**
Run: `cd frontend && npm run build:dist`
Expected: exit 0.
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
1. Hard-reload `http://localhost:8082` (cache ignoré), login `Matthieu`/`admin`.
2. Ouvrir une tâche d'un projet **Standard** (ex. `LST-49` via « Mes tâches »).
3. Ouvrir le sélecteur « Statut ».
Expected : **5 options** (les statuts du workflow Standard) — plus aucun statut ERP (« Prêt à dev », « En dev », « Mergé », « Validation client », « Validé prod », « Abandonné »).
4. Ouvrir une tâche du projet **STARSEED** (workflow ERP, code `ERP-…`).
Expected : uniquement les statuts ERP.
- [ ] **Step 4 : Commit**
```bash
git add frontend/components/task/TaskModal.vue
git commit -m "fix(task) : sélecteur de statut filtré par le workflow du projet"
```
---
## Task 4 : Drag & drop « Mes tâches » + entêtes teintées (#1 + #4b)
**Files:**
- Create: `frontend/components/task/StatusPickerPopover.vue`
- Modify: `frontend/pages/my-tasks.vue` (template kanban ~l.394-424 ; script ~l.118-140)
- Modify: `frontend/services/tasks.ts` (réutiliser `update()` existant)
**Contexte vérifié :** `TaskCard.vue` pose déjà `dataTransfer.setData('text/plain', task.id)` au `dragstart`. `my-tasks.vue` n'a **aucun** handler `@drop`/`@dragover`. Les colonnes itèrent sur `CATEGORIES` (l.119). `tasks.value` contient les tâches affichées. `tasks.ts` expose `update(id, payload: Partial<TaskWrite>)``PATCH /tasks/{id}` ; le statut s'écrit en IRI (`status: '/api/task_statuses/{id}'`, cf. TaskModal l.1070).
- [ ] **Step 1 : Créer le popover de désambiguïsation**
`frontend/components/task/StatusPickerPopover.vue` :
```vue
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{
statuses: TaskStatus[]
x: number
y: number
}>()
const emit = defineEmits<{
pick: [status: TaskStatus]
cancel: []
}>()
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
<div
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
:style="{ left: x + 'px', top: y + 'px' }"
>
<button
v-for="s in statuses"
:key="s.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
@click="emit('pick', s)"
>
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
{{ s.label }}
</button>
</div>
</Teleport>
</template>
```
- [ ] **Step 2 : Ajouter la logique de drop dans `my-tasks.vue` (script)**
Dans `<script setup>`, ajouter les imports et l'état (près des helpers kanban, l.118+) :
```ts
import { STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
const dragOverCategory = ref<StatusCategory | null>(null)
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
const wf = task.project?.workflow
if (!wf) return []
return wf.statuses.filter(s => s.category === category)
}
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
await loadTasks() // recharge la liste (utiliser la fonction de rechargement existante)
}
function onDrop(category: StatusCategory, event: DragEvent): void {
dragOverCategory.value = null
const taskId = Number(event.dataTransfer?.getData('text/plain'))
const task = tasks.value.find(t => t.id === taskId)
if (!task) return
const candidates = statusesForTaskCategory(task, category)
if (candidates.length === 0) {
toast.error(t('myTasks.dropRefused')) // 0 statut dans cette catégorie pour ce workflow
return
}
if (candidates.length === 1) {
void applyStatus(task, candidates[0])
return
}
// ≥2 : popover de choix ancré au point de drop
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
}
function onPickerChoice(status: TaskStatus): void {
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
pendingPicker.value = null
}
```
> Adapter `loadTasks()` / `toast` / `t` aux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dans `my-tasks.vue`).
- [ ] **Step 3 : Brancher le template kanban (#1) + entêtes teintées (#4b)**
Remplacer le bloc colonne (l.397-404) par :
```vue
<div
v-for="cat in CATEGORIES"
:key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
@dragover.prevent="dragOverCategory = cat"
@dragleave="dragOverCategory = null"
@drop="onDrop(cat, $event)"
>
<div
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
>
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
</div>
```
Puis, juste avant la fermeture du `<template>` (à côté de la TaskModal), ajouter le popover :
```vue
<StatusPickerPopover
v-if="pendingPicker"
:statuses="pendingPicker.statuses"
:x="pendingPicker.x"
:y="pendingPicker.y"
@pick="onPickerChoice"
@cancel="pendingPicker = null"
/>
```
- [ ] **Step 4 : Ajouter la clé i18n `myTasks.dropRefused`**
Dans `frontend/i18n/locales/fr.json` (et les autres locales présentes), sous `myTasks` : `"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"`.
- [ ] **Step 5 : Build**
Run: `cd frontend && npm run build:dist`
Expected: exit 0.
- [ ] **Step 6 : Vérification navigateur (Chrome MCP)**
1. Hard-reload, login, aller à « Mes tâches » (vue kanban).
Expected : entêtes de colonnes **colorées** (todo indigo, in_progress bleu, blocked rouge, review ambre **texte foncé**, done sarcelle).
2. Glisser une carte d'un projet **Standard** de « À faire » vers « En cours ».
Expected : le statut passe à « En cours » (1 seul statut in_progress → direct), la carte se déplace.
3. Glisser une carte du projet **STARSEED** (workflow ERP) vers « En validation » (la catégorie `review` a ≥2 statuts ERP : En review, Mergé, Validation client).
Expected : **popover** au point de drop listant ces statuts ; le choix applique le statut.
- [ ] **Step 7 : Commit**
```bash
git add frontend/components/task/StatusPickerPopover.vue frontend/pages/my-tasks.vue frontend/i18n/locales/
git commit -m "fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées"
```
---
## Task 5 : Backend `create-task` — statut + assigné, sans priorité (#6 back)
**Files:**
- Modify: `src/Controller/Mail/MailCreateTaskController.php`
- Test: `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php`
**Contexte vérifié :** `Task::setStatus(?TaskStatus)`, `Task::setAssignee(?User)` existent. `Project::getWorkflow()` ; `Workflow::getStatuses()` est ordonné `position ASC`. Accès mail = ROLE_USER/ROLE_ADMIN (cf. `MailAccessChecker`).
- [ ] **Step 1 : Écrire le test fonctionnel (TDD) — assigné + statut, priorité ignorée**
Ajouter dans `MailTaskIntegrationControllerTest.php` (crée ses prérequis via l'EntityManager) :
```php
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$admin = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']);
$client->loginUser($admin);
// Projet existant (fixtures) + son workflow / premier statut + un message mail existant
$project = $em->getRepository(\App\Entity\Project::class)->findOneBy([]);
self::assertNotNull($project, 'Au moins un projet doit exister dans les fixtures');
$status = $project->getWorkflow()->getStatuses()->first();
$message = $em->getRepository(\App\Entity\MailMessage::class)->findOneBy([]);
self::assertNotNull($message, 'Au moins un message mail doit exister (fixtures ou sync)');
$client->request(
'POST',
'/api/mail/messages/'.$message->getId().'/create-task',
[], [], ['CONTENT_TYPE' => 'application/json'],
json_encode([
'projectId' => $project->getId(),
'assigneeId' => $admin->getId(),
'statusId' => $status->getId(),
'priorityId' => 999, // doit être ignoré
])
);
self::assertResponseStatusCodeSame(201);
$payload = json_decode($client->getResponse()->getContent(), true);
$task = $em->getRepository(\App\Entity\Task::class)->find($payload['taskId']);
self::assertSame($status->getId(), $task->getStatus()?->getId());
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
self::assertNull($task->getPriority(), 'priorityId ne doit plus être pris en compte');
}
```
> Si les fixtures ne contiennent pas de `MailMessage`, créer dans le test un `MailConfiguration` + `MailFolder` + `MailMessage` minimal via l'EM (adapter aux champs requis des entités), ou charger un dump mail. Le test échoue tant que le contrôleur n'est pas modifié.
- [ ] **Step 2 : Lancer le test (doit échouer)**
Run: `make test` (ou `docker exec php-lesstime-fpm php bin/phpunit --filter testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority`)
Expected : FAIL (assignee/status non appliqués, priorityId encore lu).
- [ ] **Step 3 : Modifier le contrôleur**
Dans `MailCreateTaskController.php` :
a) Remplacer l'import `use App\Entity\TaskPriority;` par :
```php
use App\Entity\TaskStatus;
use App\Entity\User;
```
b) Dans la transaction (l.62-96), **remplacer** le bloc priorité (l.77-82) par l'assigné + le statut :
```php
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
if (null !== $assignee) {
$task->setAssignee($assignee);
}
}
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
$status = null;
if (isset($body['statusId']) && null !== $body['statusId']) {
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
}
if (null === $status) {
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
}
if (null !== $status) {
$task->setStatus($status);
}
```
- [ ] **Step 4 : Lancer le test (doit passer)**
Run: `make test`
Expected : PASS. Lancer aussi `make php-cs-fixer-allow-risky`.
- [ ] **Step 5 : Commit**
```bash
git add src/Controller/Mail/MailCreateTaskController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
git commit -m "feat(mail) : create-task applique statut + assigné, retire la priorité"
```
---
## Task 6 : Modale de création depuis un mail (#6 front)
**Files:**
- Modify: `frontend/components/mail/MailCreateTaskModal.vue`
- Modify: `frontend/services/mail.ts` (`createTaskFromMail`, ~l.184-192)
- Modify: `frontend/i18n/locales/*.json` (libellés user/statut)
- [ ] **Step 1 : Adapter le service `createTaskFromMail`**
Dans `frontend/services/mail.ts`, modifier le payload de `createTaskFromMail` : retirer `priority`, accepter `assigneeId?: number` et `statusId?: number`. Le corps POST devient :
```ts
{
projectId,
taskGroupId,
assigneeId,
statusId,
}
```
(adapter la signature TypeScript de la fonction en conséquence ; supprimer toute référence à `priority`).
- [ ] **Step 2 : Réécrire `MailCreateTaskModal.vue` sur AppModal + user + statut**
Remplacer le `<script setup>` : retirer `useTaskPriorityService`/`priorities`/`priorityId`/`priorityOptions`, ajouter le service users, le service statuts par workflow, et l'état `assigneeId` / `statusId`.
```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 { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
const props = defineProps<{
modelValue: boolean
messageId: number
messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [task: Task] }>()
const { t } = useI18n()
const auth = useAuthStore()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const userService = useUserService()
const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const assigneeId = ref<number | null>(null)
const statusId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)
const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<{ id: number, username: string }[]>([])
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 userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
// Statuts filtrés par le workflow du projet sélectionné (#2 réutilisé)
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
const statusOptions = computed(() =>
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
)
onMounted(async () => {
const [projs, us] = await Promise.all([
projectService.getAll({ archived: false }),
userService.getAll(),
])
projects.value = projs
users.value = us
})
// Au changement de projet : recharger les groupes + présélectionner le 1er statut du workflow
watch(projectId, async (pid) => {
taskGroupId.value = null
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
groups.value = []
if (!pid) return
loadingGroups.value = true
try {
groups.value = await taskGroupService.getByProject(pid)
} finally {
loadingGroups.value = false
}
})
// Reset + user par défaut = utilisateur connecté
watch(() => props.modelValue, (open) => {
if (open) {
projectId.value = null
taskGroupId.value = null
statusId.value = null
assigneeId.value = auth.user?.id ?? null
touchedProject.value = false
}
})
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,
assigneeId: assigneeId.value ?? undefined,
statusId: statusId.value ?? undefined,
})
emit('created', task)
close()
} finally {
isSubmitting.value = false
}
}
```
Puis remplacer tout le `<template>` (et le `<style>` devient inutile — AppModal gère l'animation) par :
```vue
<template>
<AppModal :model-value="modelValue" width="lg" :title="t('mail.createTaskModal.title')" @update:model-value="emit('update:modelValue', $event)">
<div class="space-y-5">
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
</div>
<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>
<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>
<div v-if="projectId">
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
</div>
<div>
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
</div>
</div>
<template #footer>
<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" />
</template>
</AppModal>
</template>
```
- [ ] **Step 3 : Ajouter les clés i18n**
Dans `mail.createTaskModal` (toutes les locales) : `statusLabel` (« Statut »), `assigneeLabel` (« Assigné à »), `assigneePlaceholder` (« Aucun »). Retirer `priorityLabel`/`priorityPlaceholder` si plus utilisées ailleurs.
- [ ] **Step 4 : Build**
Run: `cd frontend && npm run build:dist`
Expected : exit 0.
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
1. Hard-reload, login, Messagerie → ouvrir un message → « Créer une tâche ».
Expected : modale **élargie**, footer **toujours visible**, champs = Projet / Groupe / **Statut** / **Assigné** (défaut = Matthieu). Plus de champ Priorité.
2. Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
3. Créer la tâche → succès, tâche liée au mail avec le bon statut/assigné.
- [ ] **Step 6 : Commit**
```bash
git add frontend/components/mail/MailCreateTaskModal.vue frontend/services/mail.ts frontend/i18n/locales/
git commit -m "feat(mail) : création de tâche depuis mail — sélecteur user + statut (workflow), modale agrandie"
```
---
## Task 7 : Supprimer le bouton « Lier un mail » (#5)
**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (bouton ~l.487-493 ; `<MailPickerModal>` ~l.498-503 ; état `showMailPickerModal` l.627 ; `handleMailLinked` ~l.936-938)
- Delete: `frontend/components/mail/MailPickerModal.vue`
- Modify: `frontend/i18n/locales/*.json` (clé `mail.taskTab.linkButton`)
**Contexte vérifié :** `MailPickerModal` n'est utilisé **que** par TaskModal.
- [ ] **Step 1 : Retirer le bouton, la modale, l'état et le handler dans TaskModal.vue**
- Supprimer le `<MalioButton ... :label="$t('mail.taskTab.linkButton')" ... @click="showMailPickerModal = true" />` (~l.487-493).
- Supprimer le bloc `<MailPickerModal ... v-model="showMailPickerModal" ... @linked="handleMailLinked" />` (~l.498-503).
- Supprimer `const showMailPickerModal = ref(false)` (l.627).
- Supprimer la fonction `handleMailLinked` (~l.936-938).
- Retirer l'éventuel `import MailPickerModal` (si import explicite ; sinon auto-import, rien à faire).
- [ ] **Step 2 : Supprimer le composant et la clé i18n**
```bash
git rm frontend/components/mail/MailPickerModal.vue
```
Retirer la clé `mail.taskTab.linkButton` dans toutes les locales (vérifier qu'elle n'est plus référencée : `grep -rn "taskTab.linkButton" frontend/`).
- [ ] **Step 3 : Build**
Run: `cd frontend && npm run build:dist`
Expected : exit 0, aucune référence cassée.
- [ ] **Step 4 : Vérification navigateur (Chrome MCP)**
Ouvrir une tâche → onglet « Mails ».
Expected : plus de bouton « Lier un mail ». La liste des mails liés et le bouton de suppression de lien (s'il existe) restent fonctionnels.
- [ ] **Step 5 : Commit**
```bash
git add -A frontend/components/task/TaskModal.vue frontend/i18n/locales/
git commit -m "refactor(task) : suppression du bouton « Lier un mail » et de MailPickerModal"
```
---
## Task 8 : Cartes responsive (#3)
**Files:**
- Modify: `frontend/components/task/TaskCard.vue` (ligne badges ~l.42-106)
**Contexte vérifié :** badges en `rounded-full px-2 py-0.5 ... text-white` sans contrainte ; conteneur `mt-2 flex items-center gap-1.5` sans `min-w-0` ni `flex-wrap`. Décision : **2-3 tags max + « +N »**, hauteur fixe, troncature.
- [ ] **Step 1 : Titre — `line-clamp-2`**
Ligne 30, remplacer :
```vue
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
```
par :
```vue
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
```
- [ ] **Step 2 : Conteneur badges — `min-w-0` + troncature des badges**
Sur le conteneur (l.42) ajouter `min-w-0` : `class="mt-2 flex min-w-0 items-center gap-1.5"`.
Sur les badges statut/priorité/tag/deadline, ajouter `max-w-[7rem] truncate shrink-0` à la classe `rounded-full ...`. Exemple pour le statut (l.45) :
```vue
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
```
- [ ] **Step 3 : Limiter les tags à 2-3 + badge « +N »**
Remplacer la boucle des tags (l.57-64) par :
```vue
<span
v-for="tag in task.tags.slice(0, 2)"
:key="tag.id"
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
:title="tag.label"
>
{{ tag.label }}
</span>
<span
v-if="task.tags.length > 2"
class="shrink-0 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600"
:title="task.tags.slice(2).map(t => t.label).join(', ')"
>
+{{ task.tags.length - 2 }}
</span>
```
- [ ] **Step 4 : Build**
Run: `cd frontend && npm run build:dist`
Expected : exit 0.
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
Sur « Mes tâches » avec données prod (cartes à nombreux tags) : vérifier via le DOM qu'aucune carte ne déborde (mesurer `scrollWidth - clientWidth` ≤ 1 sur la ligne de badges) ; les cartes à >2 tags montrent un badge « +N » ; titres longs tronqués sur 2 lignes.
- [ ] **Step 6 : Commit**
```bash
git add frontend/components/task/TaskCard.vue
git commit -m "fix(task) : cartes responsive — troncature badges, max 2 tags + « +N », titre line-clamp"
```
---
## Task 9 : Couleurs par défaut par catégorie + migration data prod (#4a + #4c)
**Files:**
- Modify: `frontend/components/admin/WorkflowDrawer.vue` (`addStatus`, l.172-180 ; `categoryOptions` l.143-151)
- Create: `migrations/VersionYYYYMMDDHHMMSS.php`
- Modify: `src/DataFixtures/AppFixtures.php` (déjà correct — vérifier, ne rien changer si OK)
- [ ] **Step 1 : Couleur par défaut par catégorie dans `addStatus` (front)**
Dans `WorkflowDrawer.vue`, importer la palette et l'utiliser à la création :
```ts
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
```
```ts
function addStatus() {
form.statuses.push({
label: '',
color: STATUS_CATEGORY_COLOR.todo, // défaut cohérent (catégorie initiale = todo)
position: form.statuses.length,
isFinal: false,
category: 'todo',
})
}
```
Et, pour aligner la couleur quand l'utilisateur change la catégorie d'un statut, ajouter un watcher dans le `<script setup>` :
```ts
import type { StatusCategory } from '~/services/dto/workflow'
// (déjà importé pour le type ; sinon ajouter)
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
cats.forEach((cat, i) => {
// si la catégorie vient de changer ET que la couleur correspond encore au défaut de l'ancienne catégorie, réaligner
if (prev && cat !== prev[i] && form.statuses[i] && form.statuses[i].color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
form.statuses[i].color = STATUS_CATEGORY_COLOR[cat]
}
})
}, { deep: false })
```
> Ce watcher ne réécrase **pas** une couleur personnalisée (il n'agit que si la couleur courante = défaut de l'ancienne catégorie).
- [ ] **Step 2 : Build + vérif front**
Run: `cd frontend && npm run build:dist` (exit 0). Vérifier en navigateur : ajouter un statut → couleur par défaut indigo ; changer sa catégorie vers « En cours » alors qu'il a la couleur par défaut → la couleur passe au bleu `#4A90D9`.
- [ ] **Step 3 : Générer la migration de correction data**
Run: `make shell` puis `php bin/console make:migration` **n'est pas adapté** (pas de diff de schéma). Créer manuellement `migrations/VersionYYYYMMDDHHMMSS.php` (timestamp courant) :
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
{
public function getDescription(): string
{
return 'Remet les couleurs classiques sur les statuts du workflow Standard (dérive data prod #4).';
}
public function up(Schema $schema): void
{
// Cible : statuts du workflow nommé "Standard", par catégorie. Ne touche pas aux autres workflows.
$map = [
'todo' => '#222783',
'in_progress' => '#4A90D9',
'blocked' => '#C62828',
'review' => '#FF8F00',
'done' => '#26A69A',
];
foreach ($map as $category => $hex) {
$this->addSql(
"UPDATE task_status SET color = :hex
WHERE category = :cat
AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)",
['hex' => $hex, 'cat' => $category]
);
}
}
public function down(Schema $schema): void
{
// Pas de rollback des couleurs (correction one-shot).
$this->throwIrreversibleMigration('Correction de couleurs non réversible.');
}
}
```
> Vérifier la signature `addSql` avec paramètres nommés de la version Doctrine Migrations utilisée ; sinon utiliser des valeurs inline (couleurs et catégories sont des constantes sûres). Confirmer le nom de colonne `workflow_id` via `\d task_status`.
- [ ] **Step 4 : Tester la migration en local (sur données prod importées)**
Run: `make migration-migrate`
Puis vérifier :
```bash
docker exec -e PGPASSWORD=root lesstime-db-1 psql -U root -p 5435 -d lesstime -c "select label,color from task_status ts join workflow w on w.id=ts.workflow_id where w.name='Standard' order by ts.position;"
```
Expected : `#222783 / #4A90D9 / #C62828 / #FF8F00 / #26A69A`.
- [ ] **Step 5 : Vérif navigateur**
Kanban d'un projet Standard + badges de cartes : couleurs classiques de retour.
- [ ] **Step 6 : Commit**
```bash
git add frontend/components/admin/WorkflowDrawer.vue migrations/
git commit -m "fix(workflow) : couleurs par défaut par catégorie + migration de correction du workflow Standard"
```
---
## Task 10 : Migrer TaskModal vers AppModal (#7)
**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (coque de la modale uniquement : Teleport/Transition/overlay + header + footer)
> À faire en dernier car TaskModal est touché par #2 et #5 ; on stabilise d'abord son contenu. La migration ne change que la **coque** (structure header/body/footer), pas la logique métier.
- [ ] **Step 1 : Remplacer la coque par AppModal**
Envelopper le contenu existant dans `<AppModal :model-value="isOpen" width="lg" @update:model-value="isOpen = $event">`, déplacer le titre dans le slot `#title` (ou prop `title`), placer le corps actuel dans le slot par défaut et la barre d'actions (Supprimer / Annuler / Enregistrer, ~l.507-549) dans `<template #footer>`. Retirer le `Teleport`/`Transition`/overlay et le `max-h`/`overflow` manuels désormais gérés par AppModal.
> Conserver tels quels les sous-modales internes (ConfirmDeleteTaskModal, etc.) et la logique `close()` (qui bloque la fermeture si une confirmation est ouverte) — la connecter au `@update:model-value` d'AppModal.
- [ ] **Step 2 : Build**
Run: `cd frontend && npm run build:dist`
Expected : exit 0.
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
Ouvrir une tâche avec beaucoup de contenu (description longue) sur un viewport normal.
Expected : header et **footer (Supprimer/Annuler/Enregistrer) toujours visibles**, body scrollable au milieu. Mesurer que le bouton « Enregistrer » est dans le viewport (`getBoundingClientRect().bottom <= window.innerHeight`).
- [ ] **Step 4 : Commit**
```bash
git add frontend/components/task/TaskModal.vue
git commit -m "refactor(task) : TaskModal migré sur AppModal (footer sticky)"
```
---
## Self-Review — couverture spec
| Chantier spec | Task(s) | Couvert |
|---|---|---|
| #1 D&D | Task 4 | ✅ handlers + popover + par-workflow |
| #2 Sélecteur statut | Task 3 (+ réutilisé Task 6) | ✅ |
| #3 Cartes responsive | Task 8 | ✅ troncature + N |
| #4 Couleurs | Task 1 (palette), Task 4 (entêtes), Task 9 (migration + défauts) | ✅ a/b/c |
| #5 Bouton lier mail | Task 7 | ✅ |
| #6 Création depuis mail | Task 5 (back) + Task 6 (front) | ✅ |
| #7 Modale réutilisable | Task 2 (composant) + Task 6/10 (migrations) | ✅ |
| #8 MalioSelect catégorie | déjà fait (hors plan) | ✅ |
**Risques / points de vigilance pour l'exécutant :**
- Noms de fonctions/variables existants dans `my-tasks.vue` (rechargement des tâches, toast, `t`) et `TaskModal.vue` (projet effectif) — à raccorder aux noms réels.
- `MailMessage` non garanti dans les fixtures → adapter le test backend (Task 5) ou importer un dump mail.
- Toujours **hard-reload sans cache** après chaque `build:dist`.

View File

@@ -0,0 +1,571 @@
# Réorganisation gestion employés — Plan d'implémentation
> **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:** Sortir l'édition des informations RH du `UserDrawer` (qui ne garde que la case « Employé ») vers un onglet « Employés » dédié dans `team-absences`, avec une liste users⋈soldes et un drawer d'édition.
**Architecture:** Réorganisation 100 % frontend. Les champs employé existent déjà sur l'entité `User` (backend) et le DTO `UserData`/`UserWrite` ; la persistance passe par `usersService.update()` (PATCH partiel, sans écrasement). La liste de l'onglet joint `usersService.getAll()` (filtré `isEmployee`) avec `absenceService.getBalances({ type: 'cp' })`.
**Tech Stack:** Nuxt 4 / Vue 3 Composition API, TypeScript, composants `@malio/layer-ui` (MalioDate, MalioSelect, MalioInputText, MalioDataTable, MalioDrawer, MalioCheckbox), i18n `@nuxtjs/i18n`.
**Conventions projet :**
- 4 espaces d'indentation, TypeScript strict.
- Pas de framework de test frontend → vérification au navigateur (serveur dev sur `http://localhost:3002`, Chrome DevTools MCP) + compilation HMR sans erreur console.
- **Commits gérés par l'utilisateur** : ne committer qu'après son feu vert explicite (règle CLAUDE.md). Les étapes « Commit » sont fournies mais à déclencher sur demande. Format : `<type>(<scope>) : <message>`.
---
### Task 1 : Clés i18n
**Files:**
- Modify: `frontend/i18n/locales/fr.json` (objet `absences.admin`)
- [ ] **Step 1 : Ajouter l'onglet et le bloc `employees`**
Dans `absences.admin`, ajouter `"employees"` à `tabs`, et un nouveau bloc `employees`. Repérer le bloc existant :
```json
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes" },
```
le remplacer par :
```json
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes", "employees": "Employés" },
```
puis ajouter, à la suite des clés de `admin` (par ex. après `"adjust"`), le bloc :
```json
"employees": {
"columns": {
"name": "Nom",
"contract": "Contrat",
"cpTaken": "CP pris",
"cpRemaining": "CP restants"
},
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
"noContract": "—",
"drawer": {
"title": "Informations employé",
"save": "Enregistrer"
},
"fields": {
"hireDate": "Date d'embauche",
"endDate": "Date de sortie",
"contractType": "Type de contrat",
"familySituation": "Situation familiale",
"workTimeRatio": "Temps de travail (ex : 1.0)",
"annualLeaveDays": "CP annuels (jours)",
"referencePeriodStart": "Début période réf. (MM-DD)",
"initialLeaveBalance": "Solde CP initial",
"nbChildren": "Nombre d'enfants"
},
"contract": {
"cdi": "CDI",
"cdd": "CDD",
"stage": "Stage",
"alternance": "Alternance",
"autre": "Autre"
},
"family": {
"celibataire": "Célibataire",
"marie": "Marié(e)",
"pacse": "Pacsé(e)",
"divorce": "Divorcé(e)",
"veuf": "Veuf(ve)"
}
}
```
- [ ] **Step 2 : Vérifier la validité JSON**
Run: `cd frontend && python3 -c "import json; json.load(open('i18n/locales/fr.json')); print('OK')"`
Expected: `OK`
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(absences) : clés i18n onglet et drawer employés"
```
---
### Task 2 : Composant `EmployeeDrawer.vue`
**Files:**
- Create: `frontend/components/absence/EmployeeDrawer.vue`
- [ ] **Step 1 : Créer le composant**
Crée `frontend/components/absence/EmployeeDrawer.vue` avec ce contenu exact :
```vue
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<div>
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
</div>
</template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
<MalioSelect
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
:options="contractOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioSelect
v-model="form.familySituation"
:label="$t('absences.admin.employees.fields.familySituation')"
:options="familyOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioInputText
v-model="form.workTimeRatio"
:label="$t('absences.admin.employees.fields.workTimeRatio')"
input-class="w-full"
/>
<MalioInputText
v-model="form.annualLeaveDays"
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
input-class="w-full"
/>
<MalioInputText
v-model="form.referencePeriodStart"
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
input-class="w-full"
/>
<MalioInputText
v-model="form.initialLeaveBalance"
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
input-class="w-full"
/>
<MalioInputText
v-model="form.nbChildren"
:label="$t('absences.admin.employees.fields.nbChildren')"
input-class="w-full"
/>
<div class="col-span-full mt-2 flex justify-end">
<MalioButton
:label="$t('absences.admin.employees.drawer.save')"
button-class="w-auto px-6"
:disabled="submitting"
@click="save"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
const props = defineProps<{
modelValue: boolean
user: UserData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const { t } = useI18n()
const { update } = useUserService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const submitting = ref(false)
const contractOptions = [
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
]
const familyOptions = [
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
]
const form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
familySituation: null as FamilySituation | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '0',
nbChildren: '0',
})
function hydrate(u: UserData | null) {
if (!u) return
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
form.contractType = u.contractType ?? null
form.familySituation = u.familySituation ?? null
form.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
form.nbChildren = String(u.nbChildren ?? 0)
}
watch(() => props.modelValue, (isOpen) => {
if (isOpen) hydrate(props.user)
})
async function save() {
if (!props.user) return
submitting.value = true
try {
await update(props.user.id, {
isEmployee: true,
hireDate: form.hireDate || null,
endDate: form.endDate || null,
contractType: form.contractType,
familySituation: form.familySituation,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
nbChildren: Number(form.nbChildren) || 0,
})
emit('saved')
open.value = false
} finally {
submitting.value = false
}
}
</script>
```
- [ ] **Step 2 : Vérifier la compilation**
Le serveur dev (`http://localhost:3002`) recompile à la sauvegarde. Vérifier qu'aucune erreur de compilation/HMR n'apparaît dans la console du terminal `make dev-nuxt` ni dans la console navigateur. (Le composant n'est pas encore monté ; cette étape ne fait que valider la syntaxe.)
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/components/absence/EmployeeDrawer.vue
git commit -m "feat(absences) : drawer d'édition des informations employé"
```
---
### Task 3 : Onglet « Employés » dans `team-absences`
**Files:**
- Modify: `frontend/pages/team-absences.vue`
- [ ] **Step 1 : Ajouter l'import du service users et le type**
Après les imports existants (`useAbsenceHelpers`), ajouter :
```ts
import { useUserService } from "~/services/users";
import type { UserData } from "~/services/dto/user-data";
```
Et après la déclaration `type BalanceRow = ...`, ajouter le type de ligne :
```ts
type EmployeeRow = UserData & {
contractText: string;
cpTakenText: string;
cpRemainingText: string;
};
```
- [ ] **Step 2 : Ajouter l'onglet à `tabs`**
Remplacer le tableau `tabs` (qui se termine par l'onglet `balances`) en ajoutant l'entrée employés :
```ts
const tabs = [
{
key: "requests",
label: t("absences.admin.tabs.requests"),
icon: "mdi:format-list-bulleted",
},
{
key: "calendar",
label: t("absences.admin.tabs.calendar"),
icon: "mdi:calendar-month",
},
{
key: "balances",
label: t("absences.admin.tabs.balances"),
icon: "mdi:scale-balance",
},
{
key: "employees",
label: t("absences.admin.tabs.employees"),
icon: "mdi:account-group",
},
];
```
- [ ] **Step 3 : Ajouter l'état, les colonnes et les lignes de l'onglet**
Après `const balances = ref<AbsenceBalance[]>([]);`, ajouter :
```ts
const employees = ref<UserData[]>([]);
const employeeDrawerOpen = ref(false);
const selectedEmployee = ref<UserData | null>(null);
```
Après `const balanceRows = computed(...)`, ajouter colonnes + lignes :
```ts
const employeeColumns = [
{ key: "username", label: t("absences.admin.employees.columns.name") },
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
];
const employeeRows = computed<EmployeeRow[]>(() => {
// Map user.id -> solde CP de la période courante.
const cpByUser = new Map<number, AbsenceBalance>();
for (const b of balances.value) {
if (b.type === "cp") cpByUser.set(b.user.id, b);
}
const dash = t("absences.admin.employees.noContract");
return employees.value.map((u) => {
const cp = cpByUser.get(u.id);
return {
...u,
contractText: u.contractType ?? dash,
cpTakenText: cp ? formatDays(cp.taken) : dash,
cpRemainingText: cp ? formatDays(cp.available) : dash,
};
});
});
```
- [ ] **Step 4 : Ajouter le chargement et l'ouverture du drawer**
Après `async function loadBalances() {...}`, ajouter :
```ts
async function loadEmployees() {
const all = await useUserService().getAll();
employees.value = all.filter((u) => u.isEmployee);
}
function openEmployee(item: Record<string, unknown>) {
selectedEmployee.value = item as EmployeeRow;
employeeDrawerOpen.value = true;
}
```
Puis inclure `loadEmployees()` au montage. Remplacer le `onMounted` existant :
```ts
onMounted(async () => {
await Promise.all([reloadRequests(), loadBalances()]);
});
```
par :
```ts
onMounted(async () => {
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
});
```
- [ ] **Step 5 : Ajouter le slot d'onglet dans le template**
Juste après la fermeture du slot `</template>` de l'onglet `#balances` (avant `</MalioTabList>`), ajouter :
```vue
<!-- Employees -->
<template #employees>
<div class="min-h-[30rem] pt-10">
<MalioDataTable
:columns="employeeColumns"
:items="employeeRows"
:total-items="employeeRows.length"
:empty-message="$t('absences.admin.employees.empty')"
@row-click="openEmployee"
/>
</div>
</template>
```
- [ ] **Step 6 : Monter le drawer employé**
Après le composant `<AbsenceBalanceAdjustDrawer ... />` (avant `</div>` de fin de template), ajouter :
```vue
<EmployeeDrawer
v-model="employeeDrawerOpen"
:user="selectedEmployee"
@saved="loadEmployees"
/>
```
- [ ] **Step 7 : Vérification navigateur**
Aller sur `http://localhost:3002/team-absences`, onglet « Employés ». Vérifier : liste des users `isEmployee` avec Nom / Contrat / CP pris / CP restants ; clic sur une ligne ouvre le drawer ; aucune erreur console.
- [ ] **Step 8 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/pages/team-absences.vue
git commit -m "feat(absences) : onglet Employés (liste + ouverture drawer)"
```
---
### Task 4 : Allègement du `UserDrawer`
**Files:**
- Modify: `frontend/components/user/UserDrawer.vue`
- [ ] **Step 1 : Réduire le bloc RH du template à la seule case**
Remplacer le bloc (lignes ~74-107) :
```vue
<!-- RH / Absences -->
<div class="mt-6 border-t border-neutral-200 pt-4">
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
<div v-if="form.isEmployee" class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<!-- ... tous les champs détaillés ... -->
</div>
</div>
```
par :
```vue
<!-- RH / Absences -->
<div class="mt-6 border-t border-neutral-200 pt-4">
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
Les informations RH (contrat, dates, CP) se gèrent dans Absences équipe onglet Employés.
</p>
</div>
```
- [ ] **Step 2 : Nettoyer l'état du formulaire**
Dans `const form = reactive({...})`, supprimer les champs détaillés et ne garder que `isEmployee`. Résultat :
```ts
const form = reactive({
username: '',
password: '',
roles: [] as string[],
clientId: null as number | null,
allowedProjectIds: [] as number[],
isEmployee: false,
})
```
- [ ] **Step 3 : Nettoyer l'hydratation à l'ouverture**
Dans le `watch(() => props.modelValue, ...)`, supprimer toutes les lignes `form.hireDate = ...``form.nbChildren = ...` des deux branches (`props.item` et `else`). Conserver `form.isEmployee = props.item.isEmployee ?? false` (branche édition) et `form.isEmployee = false` (branche création).
- [ ] **Step 4 : Ne plus envoyer les champs détaillés dans le payload**
Dans `handleSubmit`, réduire le `payload` aux champs de compte + `isEmployee` :
```ts
const payload: UserWrite = {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
isEmployee: form.isEmployee,
}
if (form.password) {
payload.plainPassword = form.password
}
```
- [ ] **Step 5 : Supprimer les imports/constantes devenus inutiles**
Dans le `<script setup>` : supprimer `contractOptions` et `familyOptions` (constantes locales) ; retirer `ContractType, FamilySituation` de l'import `~/services/dto/user-data` (garder `UserData, UserWrite`). Vérifier qu'aucune autre référence ne subsiste.
Run: `cd frontend && grep -n "contractOptions\|familyOptions\|ContractType\|FamilySituation\|hireDate\|nbChildren" components/user/UserDrawer.vue`
Expected: aucune ligne (sortie vide).
- [ ] **Step 6 : Vérification navigateur**
Aller sur `http://localhost:3002/admin`, ouvrir un utilisateur. Vérifier : seule la case « Employé » + la note ; cocher/décocher et enregistrer fonctionne ; rouvrir un employé déjà renseigné depuis l'onglet Employés → ses champs RH sont intacts (non écrasés par l'enregistrement du UserDrawer). Aucune erreur console.
- [ ] **Step 7 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/components/user/UserDrawer.vue
git commit -m "refactor(users) : UserDrawer ne gère plus que le flag Employé"
```
---
### Task 5 : Vérification de bout en bout
**Files:** aucun (vérification navigateur via Chrome DevTools MCP)
- [ ] **Step 1 : Flux complet**
1. `admin` → ouvrir un user non-employé → cocher « Employé » → enregistrer.
2. `team-absences` → onglet « Employés » → l'utilisateur apparaît.
3. Clic sur sa ligne → `EmployeeDrawer` s'ouvre → renseigner dates (JJ/MM/AAAA), contrat, CP annuels → enregistrer.
4. La liste se recharge ; rouvrir la ligne → valeurs persistées.
5. Retour `admin` sur le même user → seule la case « Employé » (toujours cochée), pas de champ RH.
- [ ] **Step 2 : Contrôle console**
Vérifier l'absence d'erreurs/warnings Vue sur les trois écrans (admin, onglet Employés, drawer).
- [ ] **Step 3 : Commit final éventuel** (sur feu vert utilisateur)
Si des ajustements ont été faits pendant la vérification, les committer avec un message approprié.
---
## Self-review (couverture du spec)
- UserDrawer réduit à la case « Employé » → Task 4. ✅
- Onglet « Employés » admin-only (page déjà `middleware: ["admin"]`) → Task 3. ✅
- Liste Nom · Contrat · CP pris · CP restants (users ⋈ soldes cp) → Task 3 (Steps 3-5). ✅
- Drawer d'édition en composants Malio (MalioDate/MalioSelect/MalioInputText) → Task 2. ✅
- Persistance via `usersService.update` (PATCH partiel) → Task 2 (Step 1, `save`). ✅
- i18n regroupé sous `absences.admin.employees.*` + onglet → Task 1. ✅
- Pas de placeholder, types cohérents (`EmployeeRow`, `UserData`, `ContractType`/`FamilySituation`), noms de méthodes alignés (`loadEmployees`, `openEmployee`, `save`). ✅

File diff suppressed because it is too large Load Diff

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,239 @@
# Specs — Correctifs UI suite au système de workflow + UX mail/modales
> Date : 2026-05-20
> Contexte : suite à l'introduction des **workflows** (`docs/superpowers/specs/2026-05-19-project-workflows-design.md`),
> plusieurs régressions UI et points UX sont apparus. Reviews faites par Lucile Schnödt et Tristan Schnödtin.
> Ce document liste les 7 chantiers à traiter, avec problème, fichiers concernés, solution validée et points ouverts.
## Vue d'ensemble
| # | Chantier | Type | Décision |
|---|----------|------|----------|
| 1 | Drag & drop cassé dans « mes tâches » | régression workflow | Drop = changer de statut (gérer ambiguïté multi-statuts) |
| 2 | Sélecteur de statut mélange les workflows | régression workflow | Filtrer par le workflow du projet de la tâche |
| 3 | Cartes de tâche non responsive (tags) | UI | Refonte responsive + troncature |
| 4 | Couleurs de statut du workflow de base | data/UI | Remettre la palette classique |
| 5 | Bouton « lier un mail » dans l'onglet mail d'un ticket | UX mail | Supprimer le bouton |
| 6 | Création de ticket depuis un mail | UX mail | + sélecteur user, + sélecteur statut (remplace priorité), modale agrandie |
| 7 | Footer collant des modales centrées | UX global | Composant modale réutilisable (header / body scrollable / footer sticky) |
| 8 | Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) | UI/dette | Migrer vers `MalioSelect` (la lib supporte les valeurs `string`) |
## Décisions actées (2026-05-21)
Suite à la reproduction des bugs sur données prod (import local) et discussion :
- **#1** — Désambiguïsation au drop : **popover** de choix quand la catégorie cible a ≥2 statuts (0 → drop refusé + feedback ; 1 → PATCH direct ; ≥2 → popover ancré au point de drop). Résolution **par tâche** (workflow de son projet).
- **#2** — Source des statuts : `project.workflow.statuses` (déjà embarqué dans `GET /projects` et `task.project.workflow`). Le statut courant est ajouté en tête s'il est hors du workflow (pas de perte à l'enregistrement).
- **#3** — Cartes : `min-w-0` partout, titre `line-clamp-2`, badges `truncate`, **2-3 tags max + badge « +N »** (tooltip), hauteur de carte fixe.
- **#4** — Deux facettes :
- **(a) Statuts** (badges cartes + kanban projet) : dérive **data en prod** (fixtures OK). Correction par **migration Doctrine** idempotente remettant les hex classiques sur le workflow Standard.
- **(b) Catégories** (entêtes multi-projets de « Mes tâches », aujourd'hui grises) : nouvelle constante front **`STATUS_CATEGORY_COLOR`** (5 hex classiques) → **bandeau teinté** sur les entêtes, **texte auto noir/blanc** selon la luminance.
- **(c)** Couleur par défaut **par catégorie** dans `addStatus()` (au lieu de `#222783` systématique) pour éviter une nouvelle dérive.
- **#5** — Suppression du bouton « Lier un mail » + `MailPickerModal` + état/handler + clé i18n (`MailPickerModal` n'est utilisé que par TaskModal — vérifié).
- **#6** — + sélecteur **user** (défaut pré-rempli) ; priorité **remplacée** par sélecteur **statut** (filtré workflow, rechargé au changement de projet) ; **`priority`/`priorityId` retiré** du payload et de l'endpoint backend ; statut par défaut = **1er statut du workflow par `position`** ; modale élargie via #7.
- **#7** — Composant `frontend/components/ui/AppModal.vue` (header fixe / body `flex-1 min-h-0 overflow-y-auto` / footer `shrink-0` sticky, `max-h-[90vh]`, prop `width`). Migration d'abord : TaskModal puis MailCreateTaskModal.
- **#8** — ✅ Fait : `<select>` catégorie → `MalioSelect` (la lib accepte `value: string | number | null` ; note CLAUDE.md corrigée).
---
## 1. Drag & drop cassé dans « mes tâches »
**Problème.** Le drag & drop des cartes entre colonnes ne fonctionne plus depuis l'arrivée des workflows.
**Fichiers.**
- `frontend/pages/my-tasks.vue` — colonnes kanban construites sur les **catégories canoniques** fixes (`CATEGORIES = ['todo','in_progress','blocked','review','done']`, ~l.118-127), template kanban ~l.396-424.
- `frontend/components/task/TaskCard.vue``@dragstart` / `@dragend` HTML5 natif (~l.154-162), `dataTransfer` = `task.id`.
- Pas de librairie externe (HTML5 natif).
**Cause racine.** Les colonnes sont des **catégories canoniques** (la vue agrège plusieurs projets/workflows, donc on ne peut pas afficher une colonne par statut d'un workflow précis). Or un workflow peut désormais mapper **plusieurs statuts sur une même catégorie** (ex. deux statuts « in_progress »). Au drop dans une colonne, le statut cible devient ambigu — ce qui a cassé / rendu indéterminé le changement de statut.
**Solution retenue.** Drop dans une colonne ⇒ change le statut de la tâche vers un statut de cette catégorie **dans le workflow du projet de la tâche**. Gestion de l'ambiguïté :
- Récupérer les statuts du workflow du projet de la tâche déposée, filtrés par la catégorie de la colonne cible.
- **0 statut** dans cette catégorie pour ce workflow → drop refusé (feedback visuel, pas de changement).
- **1 statut** → appliquer directement.
- **≥ 2 statuts** → afficher un mini-sélecteur (popover/menu) au point de drop pour choisir le statut exact.
**Points ouverts.**
- ⚠️ **À trancher demain** : confirmer la stratégie de désambiguïsation (popover au drop vs. choisir le statut de plus petite `position` dans la catégorie). Le popover est plus sûr mais plus de travail ; le « plus petite position » est transparent mais peut surprendre.
- Ajouter les **handlers de drop** sur les colonnes (`@dragover.prevent`, `@drop`) — actuellement absents dans `my-tasks.vue`.
- Comme la vue est multi-projets, la résolution du statut cible doit se faire **par tâche** (selon son projet → son workflow), pas globalement.
---
## 2. Sélecteur de statut mélange les statuts de tous les workflows
**Problème.** En ouvrant une tâche (modale), le sélecteur « Statut » liste **tous les statuts globaux**, donc ceux du workflow de base **et** ceux des autres workflows.
**Fichiers.**
- `frontend/components/task/TaskModal.vue``<MalioSelect v-model="form.statusId" :options="statusOptions" />` (~l.103-109) ; `statusOptions = props.statuses.map(...)` (~l.674-676).
- `frontend/pages/my-tasks.vue``:statuses="statuses"` (~l.489), chargés via `statusService.getAll()` (~l.132).
- `frontend/services/task-statuses.ts``getAll()``GET /task_statuses` (tous les statuts).
- DTO : `frontend/services/dto/task-status.ts` — le champ `workflow` existe déjà (`{ '@id', id } | string`).
**Solution retenue.** Le sélecteur ne doit proposer que les statuts du **workflow du projet de la tâche éditée**.
- Filtrer `statusOptions` sur `status.workflow` correspondant au workflow du projet de la tâche.
- Source du filtre : soit filtrer côté front la liste déjà chargée par `workflow.id`, soit charger les statuts du workflow via le service workflow (`useWorkflowService()` existe mais n'est pas utilisé ici).
- S'assurer que le statut courant de la tâche reste affiché même si édge case (ex. statut hérité d'un ancien workflow).
**Points ouverts.**
- Vérifier que la tâche/le projet expose bien l'`@id`/`id` du workflow côté payload pour faire le filtre sans appel supplémentaire.
---
## 3. Cartes de tâche non responsive (tags mal placés / trop gros)
**Problème.** Depuis l'ajout d'éléments dans la carte (statut, priorité, effort, tags, deadline, avatars…), les tags débordent ou se chevauchent quand les textes sont longs ; la carte n'est plus responsive.
**Fichiers.**
- `frontend/components/task/TaskCard.vue` — badges statut (~l.43-49) et tags (~l.58-64) : `rounded-full px-2 py-0.5 text-xs font-semibold text-white`, **sans `truncate`, sans `max-w`, sans `line-clamp`** ; conteneur `flex` sans contrainte de largeur (~l.42-106).
**Solution retenue.** Refonte du layout de la carte pour rester contenue quelle que soit la longueur des textes :
- Conteneur des badges en `flex flex-wrap gap-1` (retour à la ligne propre).
- Tags : `max-w-[...] truncate` (ou `line-clamp-1`) + `title`/tooltip pour le texte complet au survol.
- Hiérarchiser l'info : titre prioritaire (`line-clamp-2`), badges secondaires qui passent à la ligne ou se condensent.
- Option : limiter le nombre de tags affichés (ex. 2-3 + « +N »).
**Points ouverts.**
- ⚠️ Choix d'UX à valider : `flex-wrap` (tous les tags visibles, carte plus haute) vs. troncature « +N » (hauteur fixe). Décision visuelle à prendre demain (éventuellement via mockup).
---
## 4. Couleurs de statut du workflow de base à remettre
**Problème.** Les couleurs « classiques » des statuts du workflow de base ont changé ; il faut remettre les couleurs d'origine.
**Investigation faite.** Le commit `4775cbf` (palette élargie 9→18 teintes + couleur perso) ne touche **que** `ColorPicker.vue` et `ProjectDrawer.vue` — il n'a pas modifié les couleurs des statuts. Les couleurs d'origine du **workflow Standard** sont dans les fixtures :
| Catégorie | Statut | Hex classique |
|---|---|---|
| todo | À faire | `#222783` |
| in_progress | En cours | `#4A90D9` |
| blocked | Bloqué | `#C62828` |
| review | En attente de validation | `#FF8F00` |
| done | Terminé | `#26A69A` |
Source : `src/DataFixtures/AppFixtures.php:101-105` (statuts rattachés au `$standardWorkflow`, ~l.93-116).
Migration de rattachement : `migrations/Version20260519175114.php` (attache les statuts existants au workflow Standard).
**Solution retenue.** Remettre ces 5 couleurs sur les statuts du **workflow Standard/de base**.
- Vérifier en prod (et en base de dev si dérive) que les statuts du workflow Standard portent bien ces hex ; corriger ceux qui ont dérivé (via l'UI d'admin des statuts, ou un script/migration de correction des couleurs).
- Ne **pas** toucher aux couleurs des statuts des autres workflows.
**Points ouverts.**
- Confirmer où la dérive a eu lieu (prod vs. nouveaux workflows créés via l'UI avec d'autres couleurs). Si c'est un workflow Standard en prod avec des couleurs erronées → correction data ; si c'est par défaut à la création d'un workflow → ajuster les couleurs proposées par défaut.
---
## 5. Supprimer le bouton « lier un mail » dans l'onglet mail d'un ticket
**Problème.** En ouvrant un ticket, l'onglet mail propose un bouton « lier un mail » qui n'a pas d'utilité de ce côté.
**Fichiers.**
- `frontend/components/task/TaskModal.vue` — bouton « lier un mail » (~l.486-494, `mail.taskTab.linkButton`, ouvre `showMailPickerModal`) ; `<MailPickerModal>` (~l.498-503), visible si `isEditing && isMailUser`, onglet `mails`.
- Composant lié : `frontend/components/mail/MailPickerModal.vue`.
**Solution retenue.** Supprimer le bouton « lier un mail » de l'onglet mail du ticket.
- Retirer le bouton et, si plus aucun usage, le `MailPickerModal` associé + l'état `showMailPickerModal` + `handleMailLinked`.
- Nettoyer la clé i18n `mail.taskTab.linkButton` si elle n'est plus utilisée ailleurs.
**Points ouverts.**
- Vérifier que `MailPickerModal` n'est pas réutilisé ailleurs avant de le supprimer (sinon ne retirer que le bouton).
---
## 6. Création d'un ticket depuis un mail
**Problème.** Le formulaire de création depuis un mail est incomplet et la modale dépasse de l'écran.
**Fichiers.**
- `frontend/components/mail/MailCreateTaskModal.vue` — champs actuels (~l.119-224) : info source (lecture seule), Projet (requis), Groupe (optionnel), **Priorité** (optionnelle). Footer non sticky (~l.210-224).
- `frontend/services/mail.ts``createTaskFromMail()` (~l.184-192) → `POST /mail/messages/{id}/create-task` avec `{ projectId, taskGroupId, priority }`.
**Solution retenue.**
1. **Sélecteur de user (assigné).** Ajouter un sélecteur d'utilisateur. Un user par défaut est déjà appliqué ; le sélecteur permet d'en choisir un autre. (Charger la liste via le service users.)
2. **Statut à la place de la priorité.** **Retirer** le sélecteur de priorité, le **remplacer** par un sélecteur de statut. Le statut proposé doit respecter le workflow du projet sélectionné (cf. chantier #2 — réutiliser la même logique de filtrage par workflow). Au changement de projet, recharger les statuts du workflow correspondant.
3. **Agrandir la modale.** Élargir la largeur (et gérer la hauteur via body scrollable + footer sticky, cf. chantier #7) pour qu'elle ne dépasse plus.
4. **Backend.** Adapter le payload / l'endpoint `create-task` : accepter `assigneeId` (ou IRI user) et `statusId` (ou IRI statut) ; retirer/garder `priority` selon décision (ici : remplacé par statut côté UI — décider si on garde la priorité par défaut côté backend ou non).
**Points ouverts.**
- Confirmer le statut **par défaut** présélectionné (statut initial du workflow du projet).
- Décider si la priorité disparaît totalement du payload ou reste à une valeur par défaut côté backend.
---
## 7. Footer collant pour les modales centrées
**Problème.** Les modales qui s'ouvrent au milieu de l'écran ont leurs boutons d'action en bas, qui défilent avec le contenu et finissent hors écran. On veut un footer **toujours visible**.
**Constat.** Il n'existe **aucun composant modale réutilisable** (`MalioModal`/`AppModal`). Chaque modale réimplémente `Teleport` + `Transition` + header/body/footer. Footers actuels en `border-t` mais **non sticky** :
- `frontend/components/task/TaskModal.vue` (footer ~l.507-549)
- `frontend/components/mail/MailCreateTaskModal.vue` (~l.210-224)
- `frontend/components/mail/MailLinkTaskModal.vue` (~l.226-239)
- `frontend/components/mail/MailPickerModal.vue` (~l.187-201)
- `frontend/components/ui/ConfirmDeleteTaskModal.vue` (~l.11-24)
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` (~l.63-76)
**Solution retenue (décision : composant réutilisable).** Créer un composant modale réutilisable dans `frontend/components/ui/` (ex. `AppModal.vue` / `MalioModal.vue` local) :
- Structure : `Teleport to="body"` + `Transition`, overlay `fixed inset-0`, conteneur avec **hauteur max** (`max-h-[90vh]`), en flex-col :
- **header** (titre + fermeture) fixe en haut,
- **body** `flex-1 overflow-y-auto`,
- **footer** sticky en bas (`shrink-0 border-t`), via slot `#footer`.
- Props : largeur (`sm`/`md`/`lg`/`xl`), titre, `v-model` ouverture ; slots `default` (body) et `footer`.
- **Migration progressive** des modales existantes vers ce composant (commencer par celles citées par les reviews : TaskModal, MailCreateTaskModal). Ne pas tout migrer d'un coup.
**Points ouverts.**
- Nom et emplacement définitifs du composant.
- Ordre de migration (au minimum : MailCreateTaskModal #6 et TaskModal #2/#5, qui sont déjà touchées par les autres chantiers).
---
## 8. Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) → MalioSelect
**Problème.** Dans l'éditeur de statut d'un workflow, le champ « Catégorie » est un `<select>` HTML natif, visuellement incohérent avec le reste des formulaires (qui utilisent `MalioSelect`).
**Investigation faite (2026-05-21).** La note de `Lesstime/CLAUDE.md` affirmait que `MalioSelect` n'accepte que des `value: number | null` et qu'il fallait un `<select>` natif pour les enums string. **C'est faux.** Vérifié dans la source `@malio/layer-ui` (v1.4.8 installée **et** repo dev `malio-layer-ui`) :
```ts
// app/components/malio/select/Select.vue
type Option = { label: string; value: string | number | null } // string supporté
const props = withDefaults(defineProps<{ modelValue: string | number | null; ... }>(), ...)
emit('update:modelValue', v: string | number | null)
// normalizedOptions n'ajoute l'option vide {value:null} QUE si empty-option-label est passé
```
La comparaison interne utilise `===`, donc les valeurs `string` (ex. l'enum `StatusCategory` : `todo | in_progress | blocked | review | done`) fonctionnent nativement.
**Fichiers.**
- `frontend/components/admin/WorkflowDrawer.vue``<select v-model="s.category">` (~l.49-57), `categoryOptions: { value: StatusCategory, label }[]` (~l.145-151).
- Note corrigée : `Lesstime/CLAUDE.md` (section « Frontend »).
**Solution retenue (faite).** Remplacer le `<select>` natif par :
```vue
<MalioSelect
v-model="s.category"
:options="categoryOptions"
label="Catégorie"
min-width="w-44"
group-class="shrink-0"
/>
```
- Pas d'`empty-option-label` (catégorie requise → pas d'option « Aucune » `null`).
- `min-width="w-44"` pour rester compact dans la ligne `flex` (sinon défaut `w-96`).
**Points ouverts / suite possible.**
- D'autres `<select>` natifs subsistent pour des enums string et pourraient être migrés de la même façon (candidats : `AdminClientTicketTab.vue`, `AdminMailTab.vue`, `ProjectClientTickets.vue`, `ProjectWorkflowSwitchModal.vue`, `TaskModal.vue`). À traiter au cas par cas, hors scope immédiat.
---
## Découpage suggéré pour l'implémentation
Regrouper par zone pour limiter les conflits :
- **Lot A — Workflow / statuts** : #2 (filtrage statut) → réutilisé par #1 (D&D) et #6 (statut à la création mail).
- **Lot B — Cartes** : #3 (responsive) + #4 (couleurs classiques).
- **Lot C — Mail** : #5 (suppr. bouton) + #6 (form création) — dépend de #2 et #7.
- **Lot D — Modale réutilisable** : #7 (composant) puis migration de TaskModal et MailCreateTaskModal.
Ordre conseillé : **#7 (composant modale)** et **#2 (filtrage statut)** d'abord (briques réutilisées), puis #1, #6, #5, puis #3/#4.

View File

@@ -0,0 +1,119 @@
# Refonte UX du formulaire « Nouvelle demande d'absence »
Date : 2026-05-21
Composant : `frontend/components/absence/AbsenceRequestDrawer.vue`
Branche : `feat/absence-management`
## Contexte & problème
Le formulaire actuel fonctionne mais est inutilisable côté UX :
- `VueDatePicker` brut **non thématisé** (couleur violette par défaut, calendrier au rendu anglo-saxon / semaine au dimanche) → aspect « cassé », rien à voir avec PayFit.
- `<label>` et `<input type="file">` bruts au lieu des composants Malio.
- **Aucune erreur explicite** : le bouton « Valider » est simplement grisé via `canSubmit` quand un champ manque, sans dire pourquoi.
- `preview` qui échoue en silence (`catch { preview.value = null }`).
- Solde insuffisant signalé par un simple warning ambre.
Cible : reproduire l'ergonomie PayFit (référence fournie par l'utilisateur) — apparition progressive des champs, deux champs date saisissables + popup, pills de demi-journée, lignes de solde, erreurs explicites.
## Objectifs
1. Look & ergonomie alignés sur PayFit, cohérents avec le design system Malio.
2. Apparition **progressive** des champs au fil des choix.
3. **Erreurs explicites** par champ, affichées au clic « Valider », qui se vident dès correction.
4. Champs de date en français `JJ / MM / AAAA`, calendrier FR / semaine au lundi.
5. Justificatif via `MalioInputUpload`, affiché seulement si le type l'exige.
## Hors périmètre
- Aucun changement backend : payload (`AbsenceRequestWrite`), endpoints `create` / `preview` / `uploadJustification` inchangés.
- Aucune modification des autres composants du module (traités séparément dans les points #2 à #7 de la revue).
## Layout cible (drawer `max-w-xl`, 1 colonne, apparition progressive)
**Étape 1 — toujours visible**
- `Type d'absence` : `MalioSelect` (options = policies actives). Erreur inline si vide au submit.
**Étape 2 — apparaît dès qu'un type est choisi**
- `Date de début` : champ texte `JJ / MM / AAAA` saisissable + icône calendrier (popup) + bouton effacer. `VueDatePicker` mono-date, thématisé (voir ci-dessous).
- Pills demi-journée début : `Journée entière` | `Matin` | `Après-midi` (défaut : Journée entière).
- Ligne `Solde au <date début> : X jours` (valeur = `preview.available`, alignée à droite, **non repliable**).
**Étape 3 — apparaît dès que la date de début est renseignée**
- `Date de fin` : même composant que la date de début.
- Pills demi-journée fin : `Journée entière` | `Matin` (défaut : Journée entière).
- Ligne `Durée de la demande : N jours` (valeur = `preview.countedDays`).
- Ligne `Solde après validation : Y jours` (valeur = `preview.projectedAvailable`, non repliable). Si `< 0` → bandeau ambre **non bloquant** « solde après = … j, demande soumise pour validation ».
**Étape 4**
- `Justificatif` : `MalioInputUpload`, affiché **uniquement si** `selectedPolicy.justificationRequired`. Label avec `*`. Erreur inline si requis et absent.
- `Commentaire` (optionnel) : `MalioInputTextArea`, placeholder « Écrire un commentaire… ».
**Footer**
- `[ Annuler (tertiary) ] [ Valider (primary) ]`, bouton **toujours actif**.
## Datepicker — thématisation
Reprendre le pattern de `frontend/components/ui/DateFilter.vue` :
- `VueDatePicker` mono-date, `:enable-time-picker="false"`, `auto-apply`, `text-input` activé (saisie clavier), `format``JJ/MM/AAAA`.
- `locale="fr"` (chaîne) + semaine au lundi (`week-start: 1`).
- Variables CSS `--dp-primary-color: #222783`, `--dp-border-color`, `--dp-hover-color`, `--dp-font-size`, `font-family: inherit` (scopées au composant).
- `:min-date` sur la date de fin = date de début ; `:max-date` sur la date de début = date de fin.
## Pills demi-journée → payload
Segment de boutons (style pill, bordure + fond bleu primaire quand sélectionné).
| Pill début | `startHalfDay` | Pill fin | `endHalfDay` |
|------------|----------------|----------|--------------|
| Journée entière | `null` | Journée entière | `null` |
| Matin | `matin` | Matin | `matin` |
| Après-midi | `apres_midi` | — | — |
Cas particulier — **demande sur une seule journée** (`startDate == endDate`) : seules les pills de début sont pertinentes (`Journée entière` / `Matin` / `Après-midi`) ; les pills de fin sont masquées et `endHalfDay` recopie `startHalfDay`. Le décompte reste calculé par `preview` côté backend.
## Validation
État réactif `errors: { type?: string; startDate?: string; endDate?: string; justification?: string }`.
`validate()` (au clic « Valider ») remplit `errors` :
- `type` manquant → `absences.form.errors.typeRequired`
- `startDate` manquante → `absences.form.errors.startRequired`
- `endDate` manquante → `absences.form.errors.endRequired`
- `endDate < startDate``absences.form.errors.endBeforeStart`
- `preview.countedDays <= 0``absences.form.errors.zeroDays`
- justificatif requis et absent → `absences.form.errors.justificationRequired`
Si `errors` non vide → on stoppe la soumission. Des `watch` vident chaque message dès que le champ correspondant redevient valide.
Le solde insuffisant **n'est pas** une erreur bloquante (seul le bandeau ambre).
### Erreurs serveur
- `service.create` / `uploadJustification` : le toast d'erreur de `useApi` reste ; en plus, un bandeau d'erreur en tête de formulaire affiche le message renvoyé (422 de validation).
- `preview` : conserver le `catch` pour les coupures réseau, mais ne plus masquer une erreur de validation de période (afficher le message si l'API en renvoie un).
## Calcul live (preview)
Inchangé sur le principe : `watch` debouncé (300 ms) sur `[type, startDate, endDate, startHalfDay, endHalfDay]``service.preview(payload)`. Les lignes de solde et de durée se mettent à jour à partir du résultat (`available`, `countedDays`, `projectedAvailable`).
## i18n
Nouvelles clés sous `absences.form.errors.*` dans `frontend/i18n/locales/fr.json` :
`typeRequired`, `startRequired`, `endRequired`, `endBeforeStart`, `zeroDays`, `justificationRequired`, plus `absences.form.balanceAt` (« Solde au {date} ») et `absences.form.duration` (« Durée de la demande »).
## Découpage du composant
`AbsenceRequestDrawer.vue` orchestre l'état du formulaire. Pour garder le fichier focalisé, extraire :
- `AbsenceDateField.vue` : champ date thématisé + pills demi-journée (props : `modelValue`, `halfValue`, `halfOptions`, `label`, `error`, `min`/`max`).
Le reste (lignes de solde, bandeau, footer) reste inline dans le drawer.
## Critères d'acceptation
- Au chargement, seul « Type d'absence » est visible ; les sections suivantes apparaissent au fur et à mesure.
- Les dates s'affichent et se saisissent en `JJ / MM / AAAA`, calendrier en français, semaine au lundi, thème bleu primaire.
- Cliquer « Valider » sans remplir → messages d'erreur explicites sous les champs concernés ; ils disparaissent dès correction.
- Un type à justificatif obligatoire affiche le champ d'upload et bloque la soumission tant qu'aucun fichier n'est fourni.
- Une période dépassant le solde affiche le bandeau ambre mais reste soumissible.
- Le payload envoyé au backend est identique à l'actuel.

View File

@@ -0,0 +1,88 @@
# Mise en conformité légale du module Absences — Design
> **Date** : 2026-05-22
> **Branche** : `feat/absence-management`
> **Origine** : audit `2026-05-22-absence-legal-compliance-review.md`. Ce design ne traite QUE les corrections retenues (périmètre arrêté avec l'utilisateur). Les points « lourds » sont explicitement reportés.
## Objectif
Corriger les non-conformités légales **structurantes mais à faible risque** du module Absences, sans multiplier les types/paramètres (préférence utilisateur : gérer le détail via le motif plutôt que par des paramètres structurés).
## Décisions cadrées
- **Décès** : un seul type, le lien de parenté est saisi dans le **motif** (rendu obligatoire) ; pas d'éclatement par lien.
- **Points lourds reportés en backlog** (NON traités ici) : congés d'ancienneté Syntec + arrondi légal, acquisition de CP pendant arrêt maladie (loi 2024-364), politique de rétention/purge des justificatifs.
## Périmètre (6 corrections)
### 1. Les événements familiaux ne décrémentent plus de solde
**Problème** : `AbsenceType::decrementsBalance()` renvoie `true` pour tout sauf la maladie. Mariage/PACS, décès et congé parental décrémentent donc un « solde » par (type, année) qui n'a **aucune acquisition** (artefact sémantique). Légalement, ces congés sont des **droits par événement**, non déduits d'un solde annuel.
**Solution** : `decrementsBalance()` ne renvoie `true` que pour `PaidLeave` (CP).
```php
return self::PaidLeave === $this;
```
Conséquence : pour tous les autres types, `AbsenceBalanceService::reservePending/applyApproval/release` deviennent des no-op (déjà gardés par `shouldTrack()`/`decrementsBalance()`). Les demandes restent enregistrées, validées et décomptées (`countedDays`), mais ne touchent aucun `AbsenceBalance`.
**Impact données** : les fixtures ne doivent plus semer de `AbsenceBalance` pour des types ≠ CP (à vérifier dans `AppFixtures`). Aucune migration (pas de changement de schéma).
### 2. Décès — motif obligatoire, plus de forfait trompeur
**Problème** : forfait unique `daysPerEvent = 3` < minimum légal (enfant 5 j + deuil 8 j ; conjoint/parent/fratrie 3 j).
**Solution** :
- Policy décès : `daysPerEvent = null` (« selon lien de parenté », pas de forfait codé en dur faux).
- **Motif obligatoire** pour le type décès, contrôlé à la création dans **les deux** chemins : `AbsenceRequestProcessor` (REST) et `CreateAbsenceRequestTool` (MCP). Erreur explicite si `reason` vide.
- Les minimums légaux sont rappelés dans l'aide `/help` (06-absences) ; l'admin accorde le nombre de jours correct en validant les dates.
### 3. Ajout du congé naissance
**Problème** : type absent.
**Solution** : nouveau cas `AbsenceType::Birth = 'naissance'`.
- `label()` = « Naissance ».
- `decrementsBalance()` = `false` (couvert par le point 1).
- Policy par défaut (fixtures) : `daysPerEvent = 3`, `justificationRequired = true`, `countWorkingDaysOnly = true`, `active = true`.
- Frontend : ajouter « naissance » à la liste des types proposés (form Nouvelle demande) + libellés i18n + couleur de badge dans `useAbsenceHelpers`.
### 4. Congé parental = suspension
**Problème** : modélisé comme décompte de solde.
**Solution** : couvert par le point 1 (`decrementsBalance()` false). Le congé parental reste enregistré comme **absence longue justifiée**, sans impact solde. `daysPerYear`/`daysPerEvent` restent `null`. Justificatif requis (policy inchangée si déjà à true).
### 5. Garde-fou demi-journée
**Problème** : `AbsenceDayCalculator::countWorkingDays` retire `-0,5` pour `startHalfDay`/`endHalfDay` **sans vérifier** que le jour-borne est réellement décompté (week-end/férié). Sous-décompte possible.
**Solution** : n'appliquer la déduction de demi-journée que si le jour-borne (début pour `startHalfDay`, fin pour `endHalfDay`) est un jour effectivement compté. Couvert par un **test unitaire** dédié dans `AbsenceDayCalculatorTest` (TDD : test rouge d'abord).
### 6. Documenter la convention collective
**Problème** : hypothèses Syntec codées en dur, CCN nulle part formalisée.
**Solution** : paramètre de config `app.absence.convention` (valeur `"Syntec (IDCC 1486)"`), affiché :
- en sous-titre de l'onglet admin « Absences » (`AdminAbsencePolicyTab`) ;
- dans l'aide `/help` (06-absences).
Aucune logique conditionnée à cette valeur (purement documentaire/affichage).
## Hors périmètre (backlog, documenté dans le rapport d'audit)
- Congés d'ancienneté Syntec (1/2/3/4 j à 5/10/15/20 ans) + arrondi légal à l'entier supérieur.
- Acquisition de CP pendant arrêt maladie (loi 2024-364, 2 j ouvrables/mois plafonnés).
- Politique de rétention/purge des justificatifs (données de santé) et des demandes.
- Contrôle de solde négatif à l'approbation (comportement « poser le N en cours » volontairement conservé).
- Alsace-Moselle (jours fériés spécifiques).
## Tests & validation
- **TDD** : test rouge → vert pour le garde-fou demi-journée (point 5) dans `tests/Unit/Service/AbsenceDayCalculatorTest.php`.
- Test du motif décès obligatoire (création MCP) dans le cycle de vie existant ou un test ciblé.
- `make test` au vert (49 + nouveaux), `lint:container` OK, `doctrine:schema:validate` OK (aucune migration), `php-cs-fixer` propre.
- Build Nuxt OK (changements front : type naissance + i18n + libellés CCN).
- Vérif fonctionnelle : créer une demande décès sans motif → refus ; avec motif → OK et **aucun** `AbsenceBalance` créé pour le décès.
## Composants touchés
- `src/Enum/AbsenceType.php` (decrementsBalance, cas Birth, label).
- `src/State/AbsenceRequestProcessor.php` + `src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php` (motif décès obligatoire).
- `src/Service/AbsenceDayCalculator.php` (garde-fou demi-journée).
- `src/DataFixtures/AppFixtures.php` (policy naissance, décès daysPerEvent=null, plus de balance non-CP).
- `config/services.yaml` (param convention) + `AdminAbsencePolicyTab.vue` + `06-absences.md` (affichage CCN).
- `frontend/composables/useAbsenceHelpers.ts` + `frontend/components/absence/AbsenceRequestDrawer.vue` + `i18n/locales/fr.json` (type naissance).
- Tests : `AbsenceDayCalculatorTest`, `AbsenceRequestLifecycleTest`.

View File

@@ -0,0 +1,115 @@
# Audit de conformité légale & RGPD — Module « Gestion des absences » (Lesstime)
> **Date** : 2026-05-22
> **Périmètre** : code backend (`src/Enum`, `src/Entity`, `src/Service`, `src/State`, `src/Command`, `src/Controller/Absence`), fixtures, migrations et front (`frontend/components/absence`, `frontend/pages/absences.vue`, `team-absences.vue`).
> **Nature** : audit documentaire. **Aucune modification de code** n'a été réalisée.
> **Avertissement** : ce document est une analyse technique de conformité, pas un avis juridique formel. Faire valider par un conseil RH/juridique avant mise en production paie.
---
## 1. Convention collective applicable
### Constat
Le code (`AbsencePolicy` par défaut, fixtures `AppFixtures.php`) part d'hypothèses « Syntec » sans le formaliser : 25 jours **ouvrés** de CP, période de référence **1er juin → 31 mai** (`referencePeriodStart = '06-01'`), décompte en jours ouvrés par défaut (`countWorkingDaysOnly = true`).
### Convention probable
Pour une société d'édition de logiciels / services informatiques (« bureau informatique »), c'est très majoritairement la **Convention collective nationale Syntec — IDCC 1486** (« Bureaux d'études techniques, cabinets d'ingénieurs-conseils et sociétés de conseils ») qui s'applique. C'est cohérent avec les valeurs codées (25 jours ouvrés, décompte en jours ouvrés).
### ⚠️ Ambiguïté à lever
La convention applicable **ne se déduit pas de l'activité seule** : elle dépend du **code APE/NAF** de l'entreprise et de l'activité principale réelle. Une SSII/éditeur peut relever de Syntec, mais certaines structures relèvent d'autres CCN. **À confirmer** sur le bulletin de paie ou auprès de l'expert-comptable. Le module **code en dur** des hypothèses Syntec mais ne stocke nulle part la CCN de référence : à documenter.
### Sources
- Code du travail / congés payés : https://www.service-public.gouv.fr/particuliers/vosdroits/F2258
- Syntec — congés payés (25 jours ouvrés, ancienneté) : https://www.legisocial.fr/conventions-collectives-nationales/1486-syntec-bureaux-etudes-techniques-cabinets-ingenieurs-conseils-societes/conges-payes-indemnites-conges-sans-solde.html
- Syntec — congés événements familiaux (Code du travail numérique) : https://code.travail.gouv.fr/contribution/1486-les-conges-pour-evenements-familiaux
- Texte de base Syntec (Légifrance) : https://www.legifrance.gouv.fr/conv_coll/id/KALIARTI000044253087/?idConteneur=KALICONT000005635173
---
## 2. Conformité par type d'absence
Valeurs implémentées : voir `AppFixtures.php` (lignes 648-667) et `User::$annualLeaveDays = 25.0`.
| Type | Minimum légal / Syntec | Implémenté | Verdict |
|------|------------------------|------------|---------|
| **Congés payés (CP)** | Légal : 2,5 j ouvrables/mois = 30 j ouvrables/an (5 sem.). Syntec : 25 j ouvrés/an = équivalent 30 j ouvrables. Période réf. 01/06→31/05. | `daysPerYear = 25`, décompte jours ouvrés, période `06-01`, acquisition 1/12 par mois (`AccrueLeaveCommand`). | ✅ **Conforme** sur le principe. ⚠️ Réserves : (a) pas de **règle d'arrondi** légal à l'entier supérieur (art. L3141-7) ; (b) pas de **congés d'ancienneté Syntec** (1 j à 5 ans, 2 j à 10 ans, 3 j à 15 ans, 4 j à 20 ans) ; (c) acquisition figée à `annualLeaveDays/12` sans assimilation des périodes d'arrêt maladie (voir §3). |
| **Mariage / PACS** | Légal : **4 jours minimum** (L3142-4). Syntec : 4 jours ouvrés. | `daysPerEvent = 4`, justificatif requis. | ✅ **Conforme**. |
| **Décès proche** | Légal : **enfant = 5 j min** (et jusqu'à 7 j + congé deuil 8 j si enfant <25 ans) ; conjoint/partenaire = 3 j ; père/mère/beau-parent/frère/sœur = 3 j (L3142-4). Syntec : barème ≥ légal, dont **jusqu'à ~22 j** pour décès enfant <25 ans. | **Un seul** `Bereavement` à `daysPerEvent = 3`, sans distinction du lien de parenté. | ❌ **Non conforme**. Un forfait unique de 3 j est **inférieur au minimum légal** pour le décès d'un enfant (5 j) et ignore le congé de deuil (8 j) et le barème Syntec. Dérive : sous-attribution de droits. |
| **Naissance** | Légal : **3 jours min** (en sus du congé paternité). | **Type absent** de l'enum `AbsenceType`. | ❌ **Manquant**. La naissance n'est pas gérée ; aucun congé naissance ni paternité. |
| **Congé parental d'éducation** | Cadre L1225-47 s. : suspension du contrat, durée 1 an renouvelable 2×, jusqu'aux 3 ans de l'enfant, **non rémunéré**, ancienneté 1 an. | `ParentalLeave` : `daysPerYear`/`daysPerEvent` null, `decrementsBalance() = true` (décrémente un solde). | ⚠️ **Modélisation incorrecte**. Le congé parental est une **suspension longue** (mois/années), pas un décompte sur solde annuel. Le traiter comme une absence qui « décrémente un solde » (vide ici, donc 0) n'a pas de sens métier. À modéliser comme suspension, sans solde. |
| **Arrêt maladie** | Indemnisé par la Sécu ; **ne se déduit jamais des CP**. Depuis loi 2024-364 du 22/04/2024 : acquiert **2 j ouvrables/mois** de CP (maladie non pro). | `SickLeave` : `decrementsBalance() = false` → ne touche aucun solde. | ✅ **Conforme** sur le non-décompte. ⚠️ **Manque** : l'acquisition de CP pendant l'arrêt (2 j/mois) n'est pas implémentée — `AccrueLeaveCommand` crédite `annualLeaveDays/12` quel que soit le statut, sans tenir compte des arrêts. Impact paie potentiel. |
### Décompte des jours (`AbsenceDayCalculator`)
- ✅ Dimanche jamais compté ; samedi compté seulement en « jours ouvrables » ; jours fériés exclus ; demi-journées aux bornes à -0,5. Logique **correcte**.
-`PublicHolidayProvider` : 11 jours fériés métropole + Pâques/Ascension/Pentecôte via Computus. Correct **hors Alsace-Moselle** (Vendredi saint + 26/12 non gérés — documenté comme hors périmètre, à signaler si salariés concernés).
- ⚠️ La **demi-journée à -0,5** est appliquée sans vérifier que la borne tombe sur un jour décompté : si `startHalfDay` est posé alors que le jour de début est un week-end/férié (non compté), on retire quand même 0,5, ce qui peut **sous-décompter**. Cas limite à border côté validation.
---
## 3. Constats RGPD / dérive
### 🔴 BLOQUANT — Fuite des données RH/familiales à tous les utilisateurs authentifiés
**Fichiers** : `src/Entity/User.php` (lignes 32-37, groupes `user:list`), `config/packages/security.yaml` (ligne 69).
Les opérations API `GET /api/users` (collection) et `GET /api/users/{id}` n'ont **aucun attribut `security`** : seule la règle globale `^/api → IS_AUTHENTICATED_FULLY` s'applique. **N'importe quel salarié** (`ROLE_USER`) peut donc lister tous ses collègues et lire le groupe `user:list`, qui expose : `familySituation`, `nbChildren`, `hireDate`, `endDate`, `contractType`, `workTimeRatio`, `annualLeaveDays`, `initialLeaveBalance` et **`roles`**.
C'est une violation directe des principes RGPD de **minimisation** et de **limitation d'accès** (référentiel CNIL RH) : la situation familiale et le nombre d'enfants d'un collègue n'ont aucune raison d'être accessibles à un pair. Gravité maximale (donnée personnelle sensible au sens RH diffusée largement).
### 🟠 MOYEN — Collecte de `familySituation` / `nbChildren` non justifiée (non-minimisation)
**Fichiers** : `User.php` (119-125), `EmployeeDrawer.vue`, `Serializer.php` (MCP, 392-393).
Recherche d'usage exhaustive : ces deux champs sont **stockés, saisis dans le formulaire RH et exposés (API + MCP), mais ne sont utilisés dans AUCUN calcul** d'absence (ni `AbsenceDayCalculator`, ni `AbsenceBalanceService`, ni `AccrueLeaveCommand`, ni les policies). Or :
- Le module gère le décès via un forfait unique sans lien de parenté → `familySituation`/`nbChildren` ne servent pas au congé décès.
- La naissance et le congé parental ne sont pas calculés à partir de `nbChildren`.
Conséquence : **collecte sans finalité opérationnelle** = violation du principe de minimisation (art. 5.1.c RGPD). Soit ces champs servent réellement un calcul légal (alors les implémenter et le documenter dans le registre des traitements), soit ils doivent être **supprimés**. En l'état c'est une **dérive de collecte**. De plus la situation familiale peut révéler indirectement l'orientation/la vie privée → prudence renforcée.
### 🟠 MOYEN — Exposition de données RH via le MCP
**Fichier** : `src/Mcp/Tool/Serializer.php` (392-393). Le serializer MCP renvoie `familySituation` et `nbChildren`. Le serveur MCP HTTP **pointe sur la PROD** (cf. mémoire projet). Toute intégration MCP (assistant IA, scripts) peut donc aspirer ces données familiales. À restreindre / retirer du payload MCP.
### 🟡 MINEUR — Justificatif et motif : données potentiellement sensibles
**Fichiers** : `AbsenceRequest::$reason`, `justificationFileName`, `AbsenceJustification*Controller`.
- Le **contrôle d'accès au justificatif est correct** (propriétaire ou admin uniquement, `AbsenceJustificationDownloadController` lignes 38-40). ✅
- Mais un justificatif d'**arrêt maladie** peut contenir des **données de santé**. Le champ `reason` (texte libre) peut aussi en contenir. Recommandations CNIL : ne stocker que le **volet administratif**, durée de conservation limitée, accès tracé. Aucune **politique de rétention/purge** n'est implémentée (fichiers et demandes conservés indéfiniment).
### 🟡 MINEUR — Approbation sans contrôle de solde
**Fichier** : `AbsenceReviewProcessor.php`. L'approbation déplace les jours de `pending` vers `taken` **sans vérifier** que le solde disponible est suffisant — un solde peut devenir négatif. Pas un problème RGPD mais une **dérive de calcul** (congés non acquis posables sans alerte). À noter : `getAvailable()` autorise volontairement de poser les CP « en cours d'acquisition » (N), ce qui est un **choix d'entreprise admis** mais à confirmer (certaines entreprises n'autorisent que le N-1).
### ✅ Points conformes
- Accès aux `AbsenceRequest` / `AbsenceBalance` : les providers (`AbsenceRequestProvider`, `AbsenceBalanceProvider`) **filtrent bien** par propriétaire pour un non-admin (un salarié ne voit que ses propres demandes/soldes). Le filtre `user` n'est appliqué que si `ROLE_ADMIN`. ✅
- Approbation/rejet/suppression réservés à `ROLE_ADMIN`. ✅
- Upload justificatif limité au propriétaire/admin, MIME validé côté serveur, taille plafonnée 10 Mo. ✅
---
## 4. Recommandations actionnables
### Priorité 1 — Bloquant (à corriger avant prod)
1. **Restreindre `GET /api/users` et `GET /api/users/{id}`** : ajouter `security: "is_granted('ROLE_ADMIN')"` sur ces opérations, OU créer un groupe de sérialisation « annuaire » minimal (id, username, avatar) pour les non-admins et réserver `user:list` (champs RH) à l'admin via un contexte conditionné par le rôle. Retirer impérativement `familySituation`, `nbChildren`, `hireDate`, `endDate`, `contractType`, `annualLeaveDays`, `initialLeaveBalance`, `roles` de toute vue accessible à `ROLE_USER`.
### Priorité 2 — Moyen
2. **Trancher sur `familySituation` / `nbChildren`** : soit les supprimer (recommandé tant qu'aucun calcul ne les utilise), soit les rattacher à une finalité réelle (barème décès/naissance) et les inscrire au registre des traitements. Les retirer du payload MCP (`Serializer.php`).
3. **Refondre le congé décès** : remplacer le forfait unique 3 j par un barème par lien de parenté conforme au minimum légal (enfant ≥ 5 j + deuil 8 j ; conjoint/parent/fratrie 3 j) et au barème Syntec applicable.
4. **Ajouter le congé naissance** (3 j min) et, le cas échéant, le congé paternité.
5. **Remodéliser le congé parental** comme suspension de contrat (sans décompte de solde annuel).
### Priorité 3 — Mineur / robustesse
6. **Acquisition CP pendant arrêt maladie** : aligner `AccrueLeaveCommand` sur la loi 2024-364 (2 j ouvrables/mois pour maladie non pro, plafond annuel).
7. **Congés d'ancienneté Syntec** : implémenter le barème (1/2/3/4 j à 5/10/15/20 ans) et l'arrondi légal à l'entier supérieur.
8. **Politique de rétention** : définir une durée de conservation des `AbsenceRequest`, `reason` et justificatifs (santé), avec purge automatique ; tracer les accès aux justificatifs.
9. **Contrôle de solde à l'approbation** + garde-fou demi-journée sur jour non décompté dans `AbsenceDayCalculator`.
10. **Documenter la CCN de référence** dans la config (ne pas la coder en dur implicitement) et confirmer Syntec via le code APE de l'entreprise.
---
## Sources (règles légales citées)
- Congés payés (2,5 j/mois, 30 j, période 01/06→31/05) : https://www.service-public.gouv.fr/particuliers/vosdroits/F2258
- Jours ouvrés vs ouvrables : https://www.juritravail.com/Actualite/decompte-du-nombre-de-jours-de-conges-payes-les-jours-ouvrables-et-les-jours-ouvres-comment-faire/Id/2161
- Syntec — CP & ancienneté : https://www.legisocial.fr/conventions-collectives-nationales/1486-syntec-bureaux-etudes-techniques-cabinets-ingenieurs-conseils-societes/conges-payes-indemnites-conges-sans-solde.html
- Syntec — événements familiaux : https://code.travail.gouv.fr/contribution/1486-les-conges-pour-evenements-familiaux
- Code du travail — congés événements familiaux (L3142-1 s.) : https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006072050/LEGISCTA000006195795/
- Congé de deuil / décès enfant : https://travail-emploi.gouv.fr/les-conges-pour-evenements-familiaux-et-le-conge-de-deuil
- Congé parental d'éducation (L1225-47 s.) : https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006195596/
- CP pendant arrêt maladie (loi 2024-364) : https://code.travail.gouv.fr/information/acquisition-de-conges-payes-pendant-un-arret-maladie-les-nouvelles-regles
- CNIL — données de santé / employeur : https://www.cnil.fr/fr/cnil-direct/question/donnees-sur-la-sante-un-employeur-peut-il-les-connaitre
- CNIL — référentiel gestion RH (minimisation) : https://www.cnil.fr/sites/default/files/atoms/files/referentiel_grh_novembre_2019_0.pdf

View File

@@ -0,0 +1,96 @@
# Réorganisation de la gestion des employés (module Absences)
Date : 2026-05-22
Branche : `feat/absence-management`
Statut : design approuvé, prêt pour plan d'implémentation
## Contexte
Aujourd'hui, le `UserDrawer` (admin → utilisateurs) porte deux responsabilités mélangées :
1. l'administration du compte (nom, mot de passe, rôles, client, projets) ;
2. **tout le détail RH/employé** : case « Employé » + un bloc de champs (date d'embauche, date de sortie, type de contrat, situation familiale, temps de travail, CP annuels, début de période de référence, solde CP initial, nombre d'enfants).
Ces champs existent déjà sur l'entité `User` (backend) et dans le DTO `UserData`/`UserWrite` (frontend). La persistance se fait via l'API user (`PATCH /api/users/{id}`).
On veut séparer ces deux préoccupations : le `UserDrawer` ne décide plus que **si un utilisateur est un employé** ; l'édition des informations RH se fait dans un espace dédié, dans le module Absences.
## Objectifs
- `UserDrawer` : ne conserver que la case à cocher « Employé ».
- `team-absences` : ajouter un onglet « Employés » (la page est déjà `middleware: ["admin"]`, donc admin-only).
- L'onglet liste les utilisateurs marqués `isEmployee` avec leurs soldes de congés.
- Un drawer dédié permet d'éditer les informations RH d'un employé.
Hors périmètre : création d'utilisateur (reste dans l'admin), modification du flag `isEmployee` ailleurs que dans le `UserDrawer`, backend (déjà en place).
## Composants
### 1. `frontend/components/user/UserDrawer.vue`
- Supprimer le bloc détaillé employé (`hireDate`, `endDate`, `contractType`, `familySituation`, `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart`, `initialLeaveBalance`, `nbChildren`).
- Conserver **uniquement** la case `isEmployee` (« Employé (soumis à la gestion des absences) »).
- Le payload de sauvegarde du user **n'envoie plus** les champs détaillés, pour ne pas les écraser. Il continue d'envoyer `isEmployee` et les champs de compte existants.
- Nettoyer l'état du formulaire et les imports devenus inutiles (options contrat / situation familiale si elles ne servent plus que là).
### 2. `frontend/pages/team-absences.vue` — onglet « Employés »
- Ajouter un 4ᵉ onglet dans `tabs` : `{ key: 'employees', label: t('absences.admin.tabs.employees'), icon: 'mdi:account-group' }`.
- Slot `#employees` : `MalioDataTable` avec les colonnes **Nom · Contrat · CP pris · CP restants**.
- Clic sur une ligne → ouvre `EmployeeDrawer` avec l'utilisateur sélectionné (cohérent avec l'onglet Demandes qui ouvre le détail au clic ligne).
- Chargement des données :
- `usersService.getAll()` filtré sur `isEmployee === true` ;
- `absenceService.getBalances({ type: 'cp' })` → map par `user.id` pour récupérer `taken` (CP pris) et `available` (CP restants) ;
- fusion en lignes de tableau (`taken`/`available` à `—` si pas de solde CP pour l'employé).
- Recharger la liste après `saved` du drawer.
### 3. `frontend/components/absence/EmployeeDrawer.vue` (nouveau)
- Props : `modelValue: boolean`, `user: UserData | null`.
- Events : `update:modelValue`, `saved`.
- Formulaire en **composants Malio** :
- `MalioDate` : `hireDate`, `endDate` (valeurs ISO `YYYY-MM-DD`) ;
- `MalioSelect` : `contractType` (CDI/CDD/Stage/Alternance/Autre), `familySituation` (Célibataire/Marié/Pacsé/Divorcé/Veuf) ;
- `MalioInputText` : `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart` (MM-DD), `initialLeaveBalance`, `nbChildren`.
- À l'ouverture, initialiser le formulaire depuis `props.user` ; remettre à jour si `user` change.
- Sauvegarde : `usersService.update(user.id, { …champs employé })` ; à la réussite, émettre `saved` et fermer.
- En-tête : nom de l'employé.
### 4. i18n (`frontend/i18n/locales/fr.json`)
Nouvelles clés regroupées sous `absences.admin.employees.*` (et l'onglet sous `absences.admin.tabs.employees`) :
- onglet : `absences.admin.tabs.employees` = « Employés » ;
- colonnes liste : `absences.admin.employees.columns.{name,contract,cpTaken,cpRemaining}` ;
- drawer : `absences.admin.employees.drawer.title` + libellés de champs sous `absences.admin.employees.fields.*`.
Les libellés de contrat et de situation familiale (CDI/CDD/…, Célibataire/Marié/…) actuellement codés en dur dans `UserDrawer` sont déplacés avec leur drawer ; on les passe en clés i18n à cette occasion.
## Flux de données
```
UserDrawer --PATCH isEmployee--> User (backend)
|
team-absences (onglet Employés) |
getAll() ∩ isEmployee <-----------+
getBalances({type:'cp'}) --> map user.id -> {taken, available}
=> lignes tableau (nom, contrat, CP pris, CP restants)
|
clic ligne
v
EmployeeDrawer(user) --usersService.update--> User (backend)
|
@saved --> recharge liste
```
## Découpage / responsabilités
- `UserDrawer` : compte + flag employé. Ne connaît plus le détail RH.
- Onglet Employés : vue dérivée en lecture (jointure users ⋈ soldes), aiguille vers le drawer.
- `EmployeeDrawer` : seule unité qui édite les champs RH ; interface claire (`user` en entrée, `saved` en sortie), testable isolément.
## Vérification
- `UserDrawer` : ouvrir un user, vérifier qu'il ne reste que la case « Employé », cocher/décocher et sauvegarder sans perte des autres champs RH (qui ne sont plus envoyés).
- Onglet Employés : la liste affiche les users `isEmployee` avec CP pris/restants cohérents avec l'onglet Soldes.
- `EmployeeDrawer` : éditer un employé (dates en JJ/MM/AAAA, selects FR), sauvegarder, vérifier la persistance (recharge) et l'absence d'erreurs console.
- Vérification navigateur via Chrome DevTools sur les trois écrans.

View File

@@ -0,0 +1,141 @@
# Spec — Extension des outils MCP : module Absences + trous CRUD
Date : 2026-05-22
Branche : `feat/absence-management`
## Contexte & objectif
Le serveur MCP Lesstime (`src/Mcp/Tool/`) expose aujourd'hui les projets, tâches,
métadonnées de tâches, time tracking et workflows. Le nouveau **module Absences**
(`AbsenceRequest`, `AbsencePolicy`, `AbsenceBalance`) n'est pas exposé, et plusieurs
entités existantes n'ont qu'une couverture partielle (souvent `list` sans
create/update/delete).
Objectif : permettre de piloter l'app via MCP (assistant) sur ces domaines, en
respectant strictement la logique métier déjà en place.
## Conventions reprises de l'existant
- Une classe par outil, attribut `#[McpTool(name, description)]` sur la **classe**.
- Discovery automatique : `config/packages/mcp.yaml` scanne `src/` (exclut `DataFixtures`).
Aucune config à ajouter — créer la classe suffit.
- Constructeur : injection de repos/services + `Security`.
- `__invoke(...)` : check de rôle en première ligne (`AccessDeniedException` sinon),
validation des IDs (`InvalidArgumentException` si introuvable), retour `json_encode(...)`.
- Sérialisation centralisée dans `App\Mcp\Tool\Serializer`.
- Rôle : `ROLE_USER` pour la lecture/écriture courante ; `ROLE_ADMIN` pour les
opérations sensibles (déjà le cas pour `list-clients`/`list-users`, et pour les
opérations admin du module absences).
## Décision clé — réutilisation de la logique métier (pas les Processors)
Les Processors API Platform (`AbsenceRequestProcessor`, `AbsenceReviewProcessor`,
`AbsenceCancelProcessor`) sont liés à `Security::getUser()` (l'utilisateur courant)
et à l'`Operation` HTTP. En MCP, l'utilisateur courant est le **propriétaire du
token** (admin), or on veut pouvoir agir **au nom d'un employé**.
→ Les outils MCP **n'appellent pas les Processors** ; ils répliquent leur
orchestration en réutilisant les **services partagés** qui portent la vraie règle
métier :
- `AbsenceDayCalculator::countWorkingDays(...)` — calcul des jours décomptés.
- `AbsenceBalanceService``reservePending`, `applyApproval`, `release`, `periodFor`.
- `AbsencePolicyRepository::findOneByType(...)` — politique active du type.
- `AbsenceRequestRepository::hasOverlap(...)` — règle anti-chevauchement.
`create-absence-request` prend un `userId` explicite (l'employé cible) ;
`review`/`cancel` posent `reviewedBy` = utilisateur du token MCP.
Ainsi soldes (`pending`/`taken`/`acquired`) et statuts restent cohérents avec ce
que produit l'UI.
## Inventaire des outils
### Module Absences — `src/Mcp/Tool/Absence/` (10 outils)
| Outil | Rôle | Paramètres | Logique |
|---|---|---|---|
| `list-absence-requests` | USER | `userId?`, `status?`, `type?`, `from?`, `to?` | Filtre ; sans `userId` renvoie tout (token admin). |
| `get-absence-request` | USER | `id` | — |
| `create-absence-request` | USER | `userId`, `type`, `startDate`, `endDate`, `startHalfDay?`, `endHalfDay?`, `reason?` | Vérifie policy active + overlap, calcule `countedDays`, refuse si ≤ 0, statut `Pending`, `reservePending`. |
| `review-absence-request` | ADMIN | `id`, `decision` (`approve`\|`reject`), `rejectionReason?` | Seulement si `Pending`. Approve → `applyApproval` ; reject → `rejectionReason` requis + `release(false)`. Pose `reviewedAt`/`reviewedBy`. |
| `cancel-absence-request` | USER | `id` | `Pending``release(false)` ; `Approved` → ADMIN requis + `release(true)` ; sinon conflit. Statut `Cancelled`. |
| `delete-absence-request` | ADMIN | `id` | Suppression définitive. |
| `list-absence-policies` | USER | — | Toutes les policies (ordre `type`). |
| `update-absence-policy` | ADMIN | `id`, `daysPerYear?`, `daysPerEvent?`, `justificationRequired?`, `noticeDays?`, `countWorkingDaysOnly?`, `active?` | Seuls les champs fournis changent. |
| `list-absence-balances` | USER | `userId?`, `type?`, `period?` | Soldes filtrés. |
| `update-absence-balance` | ADMIN | `id`, `acquired?`, `acquiring?`, `taken?` | Ajustement manuel (régularisation). |
`type` et `status` acceptés en valeur d'enum string (ex. `cp`, `maladie`,
`pending`) ; erreur de validation explicite si invalide. `startDate`/`endDate`
au format `YYYY-MM-DD`.
### Trous CRUD sur l'existant
**Projets / groupes**
- `delete-project` (ADMIN) — `id`.
- `delete-group` (USER) — `id`.
**Métadonnées de tâches**`src/Mcp/Tool/TaskMeta/`
- `create-tag` / `update-tag` / `delete-tag` (USER) — `label`, `color?`.
- `create-effort` / `update-effort` / `delete-effort` (USER) — `label` (+ ordre éventuel).
- `create-priority` / `update-priority` / `delete-priority` (USER) — `label`, `color?`.
- `create-status` / `update-status` / `delete-status` (ADMIN) — **`workflowId` requis**
+ `category` (`todo`|`in_progress`|`blocked`|`review`|`done`), `label`, `color?`,
`position?`, `isFinal?`. (Les statuts ne sont PAS globaux : ils appartiennent à un workflow.)
**Clients**`src/Mcp/Tool/Reference/` (ADMIN, aligné sur `list-clients`)
- `get-client``id`.
- `create-client``name` (+ `email?`, `phone?`, `street?`, `city?`, `postalCode?`).
- `update-client``id` + champs optionnels.
- `delete-client``id`.
**Utilisateurs**`src/Mcp/Tool/Reference/` (ADMIN)
- `get-user``id` (profil complet RH).
- `update-user``id` + champs RH/profil : `isEmployee?`, `hireDate?`, `endDate?`,
`contractType?`, `workTimeRatio?`, `annualLeaveDays?`, `referencePeriodStart?`,
`initialLeaveBalance?`, `familySituation?`, `nbChildren?`.
**Hors périmètre (décision utilisateur) : pas de `create-user`, pas de modification
de `password` ni `roles` via MCP.**
## Sérialisation — ajouts à `Serializer.php`
- `absenceRequest(AbsenceRequest)` : id, user{id,username}, type{value,label},
startDate, endDate, startHalfDay, endHalfDay, countedDays, reason, status{value,label},
rejectionReason, createdAt, reviewedAt, reviewedBy, justificationFileName.
- `absencePolicy(AbsencePolicy)` : id, type{value,label}, daysPerYear, daysPerEvent,
justificationRequired, noticeDays, countWorkingDaysOnly, active.
- `absenceBalance(AbsenceBalance)` : id, user, type{value,label}, period, acquired,
acquiring, taken, pending, acquiredTotal, available.
- `client(Client)` : id, name, email, phone, street, city, postalCode.
- `userFull(User)` : id, username, roles, isEmployee, hireDate, endDate, contractType,
workTimeRatio, annualLeaveDays, referencePeriodStart, initialLeaveBalance,
familySituation, nbChildren.
## Mise à jour de la doc MCP
`config/packages/mcp.yaml` — bloc `instructions` :
- corriger la mention « statuses … are GLOBAL » (faux : par workflow) ;
- ajouter une phrase sur le domaine Absences (requests/policies/balances, lifecycle
approve/reject/cancel, `userId` pour agir au nom d'un employé).
## Découpage du plan d'implémentation (jalons)
1. **Absences** : Serializer + 10 outils + tests de cohérence des soldes.
2. **Métadonnées tâches** : delete-project/group, CRUD tag/effort/priority/status.
3. **Clients & users** : CRUD clients, get/update user + maj `mcp.yaml`.
Chaque jalon est livrable et testable indépendamment.
## Tests
Tests fonctionnels MCP (si une infra de test MCP existe) ou tests unitaires sur la
réplication de la logique de solde : créer → review(approve) → cancel et vérifier
`pending`/`taken` à chaque étape ; vérifier le refus sur chevauchement et sur
plage sans jour ouvré.
## Hors périmètre
- Mail, BookStack, Gitea, Zimbra, Notifications, TaskDocument (non demandés).
- Création d'utilisateurs et gestion mot de passe/rôles via MCP.
- Upload de justificatif d'absence via MCP.

View File

@@ -0,0 +1,69 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-md">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.admin.adjust.title') }}</h2>
</template>
<div v-if="balance" class="flex flex-col gap-4">
<p class="text-sm text-neutral-600">
{{ balance.user.username }} · {{ balance.label }} · {{ balance.period }}
</p>
<MalioInputNumber v-model="acquired" :label="$t('absences.admin.adjust.acquired')" />
<MalioInputNumber v-model="acquiring" :label="$t('absences.admin.adjust.acquiring')" />
<MalioInputNumber v-model="taken" :label="$t('absences.admin.adjust.taken')" />
<div class="flex justify-end gap-2 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton :label="$t('absences.admin.adjust.save')" :disabled="submitting" @click="submit" />
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const props = defineProps<{
modelValue: boolean
balance: AbsenceBalance | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'adjusted': []
}>()
const service = useAbsenceService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
// MalioInputNumber works with string values (v-model is string | null).
const acquired = ref('0')
const acquiring = ref('0')
const taken = ref('0')
const submitting = ref(false)
watch(() => props.balance, (b) => {
acquired.value = String(b?.acquired ?? 0)
acquiring.value = String(b?.acquiring ?? 0)
taken.value = String(b?.taken ?? 0)
}, { immediate: true })
async function submit() {
if (!props.balance) return
submitting.value = true
try {
await service.adjustBalance(props.balance.id, {
acquired: Number(acquired.value) || 0,
acquiring: Number(acquiring.value) || 0,
taken: Number(taken.value) || 0,
})
emit('adjusted')
open.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div v-if="balances.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
{{ $t('absences.noBalance') }}
</div>
<div v-else class="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
<!-- Primary balance, highlighted -->
<div v-if="primary" class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-neutral-500">{{ primary.label }}</p>
<p class="mt-1 text-3xl font-bold text-neutral-900">
{{ formatNumber(primary.available) }}
<span class="text-lg font-normal text-neutral-400">/ {{ formatNumber(acquiredTotal(primary)) }}</span>
</p>
<p class="text-xs text-neutral-400">{{ $t('absences.remaining') }}</p>
</div>
<span
class="rounded-full px-2.5 py-1 text-xs font-medium"
:style="{ backgroundColor: typeColor(primary.type) + '22', color: typeColor(primary.type) }"
>
{{ primary.period }}
</span>
</div>
<div v-if="primary" class="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div
class="h-full rounded-full transition-all"
:style="{ width: progress(primary) + '%', backgroundColor: typeColor(primary.type) }"
/>
</div>
<!-- Acquired (N-1) vs in-progress (N), as on a French payslip -->
<div v-if="primary && primary.type === 'cp'" class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-lg bg-neutral-50 px-3 py-2">
<p class="text-xs text-neutral-400">{{ $t('absences.acquiredN1') }}</p>
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquired) }}</p>
</div>
<div class="rounded-lg bg-neutral-50 px-3 py-2">
<p class="text-xs text-neutral-400">{{ $t('absences.acquiringN') }}</p>
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquiring) }}</p>
<p class="text-[10px] leading-tight text-neutral-400">{{ $t('absences.acquiringHint') }}</p>
</div>
</div>
<div v-if="primary" class="mt-2 flex justify-between text-xs text-neutral-500">
<span>{{ formatNumber(primary.taken) }} {{ $t('absences.taken') }}</span>
<span v-if="primary.pending > 0" class="text-amber-600">
{{ formatNumber(primary.pending) }} {{ $t('absences.pending') }}
</span>
</div>
<!-- Other balances, compact rows -->
<div v-if="others.length" class="mt-4 flex flex-col divide-y divide-neutral-100 border-t border-neutral-100 pt-1">
<div
v-for="balance in others"
:key="balance.id"
class="flex items-center justify-between py-2 text-sm"
>
<span class="flex items-center gap-2 text-neutral-600">
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: typeColor(balance.type) }" />
{{ balance.label }}
<span class="text-xs text-neutral-400">{{ balance.period }}</span>
</span>
<span class="text-neutral-900">
<span class="font-semibold">{{ formatNumber(balance.available) }}</span>
<span class="text-neutral-400"> / {{ formatNumber(acquiredTotal(balance)) }}</span>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
balances: AbsenceBalance[]
}>()
const { typeColor } = useAbsenceHelpers()
// Current paid-leave reference period, mirroring AbsenceBalanceService::periodFor.
const currentCpPeriod = computed<string>(() => {
const start = useAuthStore().user?.referencePeriodStart ?? '06-01'
const now = new Date()
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const startYear = md >= start ? now.getFullYear() : now.getFullYear() - 1
return `${startYear}-${startYear + 1}`
})
// The current "congés payés" balance is the headline; fall back to any CP, then any balance.
const primary = computed<AbsenceBalance | null>(() => {
const cps = props.balances.filter(b => b.type === 'cp')
return cps.find(b => b.period === currentCpPeriod.value) ?? cps[0] ?? props.balances[0] ?? null
})
const others = computed<AbsenceBalance[]>(() =>
props.balances.filter(b => b.id !== primary.value?.id),
)
function formatNumber(n: number): string {
return (Math.round(n * 2) / 2).toString()
}
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
// backend-computed field when present.
function acquiredTotal(balance: AbsenceBalance): number {
return balance.acquiredTotal ?? balance.acquired + balance.acquiring
}
function progress(balance: AbsenceBalance): number {
const total = acquiredTotal(balance)
if (total <= 0) return 0
return Math.min(100, Math.max(0, (balance.taken / total) * 100))
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(-1)">
<Icon name="mdi:chevron-left" size="22" />
</button>
<p class="text-lg font-semibold capitalize text-neutral-900">{{ monthLabel }}</p>
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(1)">
<Icon name="mdi:chevron-right" size="22" />
</button>
</div>
<!-- Weekday headers -->
<div class="grid grid-cols-7 border-b border-neutral-100 text-center text-xs font-medium text-neutral-400">
<div v-for="d in weekdays" :key="d" class="py-2">{{ d }}</div>
</div>
<!-- Grid -->
<div class="grid grid-cols-7">
<div
v-for="cell in cells"
:key="cell.key"
class="min-h-[92px] border-b border-r border-neutral-100 p-1.5"
:class="cell.holiday ? 'bg-amber-50' : (cell.inMonth ? 'bg-white' : 'bg-neutral-50')"
:title="cell.holiday ?? undefined"
>
<div class="mb-1 flex items-center gap-1">
<span v-if="cell.holiday" class="flex min-w-0 flex-1 items-center gap-1 text-[10px] font-medium text-amber-700">
<Icon name="mdi:star-four-points-outline" size="11" class="flex-shrink-0" />
<span class="truncate">{{ cell.holiday }}</span>
</span>
<span v-else class="flex-1" />
<span class="flex-shrink-0 text-xs" :class="cell.isToday ? 'font-bold text-orange-500' : 'text-neutral-400'">
{{ cell.day }}
</span>
</div>
<div class="flex flex-col gap-1">
<span
v-for="abs in cell.absences"
:key="abs.id"
class="truncate rounded px-1 py-0.5 text-[11px] font-medium text-white"
:style="{ backgroundColor: abs.status === 'pending' ? typeColor(abs.type) + 'aa' : typeColor(abs.type) }"
:title="`${abs.user.username} · ${abs.label} (${statusLabel(abs.status)})`"
>
{{ abs.user.username }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
absences: AbsenceRequest[]
}>()
const emit = defineEmits<{
'range-change': [from: string, to: string]
}>()
const service = useAbsenceService()
const { typeColor, statusLabel } = useAbsenceHelpers()
const holidays = ref<Record<string, string>>({})
const weekdays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const cursor = ref(startOfMonth(new Date()))
const monthLabel = computed(() =>
cursor.value.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
)
type Cell = { key: string; day: number; date: Date; inMonth: boolean; isToday: boolean; holiday: string | null; absences: AbsenceRequest[] }
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
function ymd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// First Monday on/before the 1st of the month
function gridStart(month: Date): Date {
const first = startOfMonth(month)
const dow = (first.getDay() + 6) % 7 // 0 = Monday
const start = new Date(first)
start.setDate(first.getDate() - dow)
return start
}
const visibleRange = computed(() => {
const start = gridStart(cursor.value)
const end = new Date(start)
end.setDate(start.getDate() + 41) // 6 weeks grid
return { start, end }
})
const cells = computed<Cell[]>(() => {
const { start } = visibleRange.value
const today = ymd(new Date())
const result: Cell[] = []
for (let i = 0; i < 42; i++) {
const date = new Date(start)
date.setDate(start.getDate() + i)
const key = ymd(date)
result.push({
key,
day: date.getDate(),
date,
inMonth: date.getMonth() === cursor.value.getMonth(),
isToday: key === today,
holiday: holidays.value[key] ?? null,
absences: props.absences.filter(a => key >= a.startDate.slice(0, 10) && key <= a.endDate.slice(0, 10)),
})
}
return result
})
async function emitRange() {
const { start, end } = visibleRange.value
emit('range-change', ymd(start), ymd(end))
try {
holidays.value = await service.getPublicHolidays(ymd(start), ymd(end))
} catch {
holidays.value = {}
}
}
function shiftMonth(delta: number) {
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
emitRange()
}
onMounted(emitRange)
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="absence-date-field">
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ label }}</label>
<MalioDate
:model-value="modelValue"
:min="min ?? undefined"
:max="max ?? undefined"
:error="error"
:clearable="true"
group-class="w-full"
@update:model-value="$emit('update:modelValue', $event)"
/>
<div v-if="showPills" class="mt-2 flex flex-wrap gap-2">
<button
v-for="opt in pillOptions"
:key="String(opt.value)"
type="button"
class="rounded-full border px-4 py-1.5 text-sm font-medium transition"
:class="half === opt.value
? 'border-primary-500 bg-primary-50 text-primary-600'
: 'border-neutral-300 text-neutral-600 hover:border-neutral-400'"
@click="$emit('update:half', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */
modelValue: string | null
half: HalfDay | null
label: string
/** 'start' shows full/morning/afternoon, 'end' shows full/morning only. */
mode?: 'start' | 'end'
error?: string
/** ISO date string "YYYY-MM-DD" or null. */
min?: string | null
max?: string | null
showPills?: boolean
}>(), {
mode: 'start',
error: '',
min: null,
max: null,
showPills: true,
})
defineEmits<{
'update:modelValue': [value: string | null]
'update:half': [value: HalfDay | null]
}>()
const { t } = useI18n()
type PillOption = { label: string; value: HalfDay | null }
const pillOptions = computed<PillOption[]>(() => {
const base: PillOption[] = [
{ label: t('absences.form.fullDay'), value: null },
{ label: t('absences.halfDay.matin'), value: 'matin' },
]
if (props.mode === 'start') {
base.push({ label: t('absences.halfDay.apres_midi'), value: 'apres_midi' })
}
return base
})
</script>

View File

@@ -0,0 +1,193 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.detail.title') }}</h2>
</template>
<div v-if="request" class="flex flex-col gap-5">
<!-- Hero -->
<div class="overflow-hidden rounded-xl border border-neutral-200 shadow-sm">
<div
class="flex items-start gap-3 p-4"
:style="{ borderLeft: `4px solid ${typeColor(request.type)}` }"
>
<span
class="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: tint(request.type), color: typeColor(request.type) }"
>
<Icon name="mdi:calendar-account" size="22" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-lg font-semibold text-neutral-900">{{ request.label }}</p>
<p class="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
<Icon name="mdi:calendar-range" size="15" />
{{ formatRange(request) }}
</p>
</div>
<StatusBadge
class="flex-shrink-0"
:label="statusLabel(request.status)"
:variant="statusVariant(request.status)"
:icon="statusIcon(request.status)"
/>
</div>
<dl class="grid grid-cols-2 divide-x divide-neutral-200 border-t border-neutral-200 bg-neutral-50">
<div class="flex items-center gap-2.5 p-3">
<span
v-if="request.user.avatarUrl"
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded-full"
>
<img :src="request.user.avatarUrl" alt="" class="h-full w-full object-cover">
</span>
<span
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600"
>
{{ initials }}
</span>
<div class="min-w-0">
<dt class="text-xs text-neutral-400">{{ $t('absences.table.employee') }}</dt>
<dd class="truncate text-sm font-medium text-neutral-800">{{ request.user.username }}</dd>
</div>
</div>
<div class="flex flex-col justify-center p-3">
<dt class="text-xs text-neutral-400">{{ $t('absences.table.days') }}</dt>
<dd class="text-sm font-semibold text-neutral-900">{{ formatDays(request.countedDays) }}</dd>
</div>
</dl>
</div>
<!-- Reason -->
<div v-if="request.reason" class="rounded-lg border border-neutral-200 p-3">
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-neutral-400">
<Icon name="mdi:comment-text-outline" size="14" />
{{ $t('absences.form.reason') }}
</p>
<p class="whitespace-pre-line text-sm text-neutral-800">{{ request.reason }}</p>
</div>
<!-- Rejection -->
<div
v-if="request.status === 'rejected' && request.rejectionReason"
class="rounded-lg border border-red-200 bg-red-50 p-3"
>
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
<Icon name="mdi:close-circle-outline" size="14" />
{{ $t('absences.detail.rejectionReason') }}
</p>
<p class="text-sm text-red-700">{{ request.rejectionReason }}</p>
</div>
<!-- Justification -->
<a
v-if="request.justificationUrl"
:href="request.justificationUrl"
target="_blank"
rel="noopener"
class="flex items-center gap-2 rounded-lg border border-neutral-200 p-3 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50"
>
<Icon name="mdi:file-document-outline" size="20" class="text-primary-500" />
<span class="flex-1 truncate">{{ request.justificationFileName || $t('absences.detail.downloadJustification') }}</span>
<Icon name="mdi:download" size="16" class="text-neutral-400" />
</a>
<!-- Timeline -->
<div>
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-neutral-400">
{{ $t('absences.detail.timeline') }}
</p>
<ol class="relative ml-1 border-l border-neutral-200 pl-5">
<li class="mb-4 last:mb-0">
<span class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white bg-primary-500 ring-1 ring-primary-200" />
<p class="text-sm font-medium text-neutral-800">{{ $t('absences.detail.created') }}</p>
<p class="text-xs text-neutral-400">{{ formatDateTime(request.createdAt) }}</p>
</li>
<li v-if="request.reviewedAt" class="last:mb-0">
<span
class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white ring-1"
:class="request.status === 'rejected'
? 'bg-red-500 ring-red-200'
: 'bg-green-500 ring-green-200'"
/>
<p class="text-sm font-medium text-neutral-800">
{{ statusLabel(request.status) }}
<span v-if="request.reviewedBy" class="font-normal text-neutral-500">
· {{ $t('absences.detail.reviewed', { name: request.reviewedBy.username }) }}
</span>
</p>
<p class="text-xs text-neutral-400">{{ formatDateTime(request.reviewedAt) }}</p>
</li>
</ol>
</div>
<div v-if="canCancel" class="flex justify-end border-t border-neutral-100 pt-4">
<MalioButton
:label="$t('absences.detail.cancel')"
variant="danger"
icon-name="mdi:cancel"
icon-position="left"
:disabled="cancelling"
@click="onCancel"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
request: AbsenceRequest | null
canCancel?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'cancelled': []
}>()
const { t } = useI18n()
const service = useAbsenceService()
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, typeColor } = useAbsenceHelpers()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const cancelling = ref(false)
const initials = computed(() => {
const name = props.request?.user.username ?? ''
return name.slice(0, 2).toUpperCase() || '?'
})
/** Type colour at ~12% opacity for soft backgrounds. */
function tint(type: AbsenceRequest['type']): string {
return `${typeColor(type)}1f`
}
function formatDateTime(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
async function onCancel() {
if (!props.request) return
if (!confirm(t('absences.detail.cancelConfirm'))) return
cancelling.value = true
try {
await service.cancel(props.request.id)
emit('cancelled')
open.value = false
} finally {
cancelling.value = false
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-md">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.review.rejectTitle') }}</h2>
</template>
<div v-if="request" class="flex flex-col gap-4">
<p class="text-sm text-neutral-600">
{{ request.user.username }} · {{ request.label }} · {{ formatRange(request) }}
</p>
<MalioInputTextArea
v-model="reason"
:label="$t('absences.review.rejectReasonLabel')"
:placeholder="$t('absences.review.rejectReasonPlaceholder')"
/>
<div class="flex justify-end gap-2 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton
:label="$t('absences.review.confirm')"
variant="danger"
:disabled="!reason.trim() || submitting"
@click="submit"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
request: AbsenceRequest | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'rejected': []
}>()
const service = useAbsenceService()
const { formatRange } = useAbsenceHelpers()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const reason = ref('')
const submitting = ref(false)
watch(open, (v) => {
if (v) reason.value = ''
})
async function submit() {
if (!props.request || !reason.value.trim()) return
submitting.value = true
try {
await service.reject(props.request.id, reason.value.trim())
emit('rejected')
open.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,296 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-xl">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.newRequest') }}</h2>
</template>
<div class="flex flex-col gap-5">
<!-- Server-side error banner -->
<div v-if="serverError" class="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
<Icon name="mdi:alert-circle-outline" size="18" class="mt-0.5 flex-shrink-0" />
<span>{{ serverError }}</span>
</div>
<!-- Step 1 type (always visible) -->
<MalioSelect
v-model="form.type"
:label="$t('absences.form.type')"
:options="typeOptions"
:empty-option-label="$t('absences.filters.allTypes')"
:error="errors.type"
group-class="w-full"
/>
<!-- Step 2 start date (revealed once a type is chosen) -->
<AbsenceDateField
v-if="showDates"
v-model="form.startDate"
v-model:half="form.startHalf"
:label="$t('absences.form.startDate')"
mode="start"
:error="errors.startDate"
:max="form.endDate"
/>
<!-- Balance at start date -->
<div v-if="preview && preview.available !== null" class="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAt', { date: startDateLabel }) }}</span>
<span class="text-neutral-900">{{ formatDays(preview.available) }}</span>
</div>
<!-- Step 3 end date (revealed once a start date is set) -->
<AbsenceDateField
v-if="showEnd"
v-model="form.endDate"
v-model:half="form.endHalf"
:label="$t('absences.form.endDate')"
mode="end"
:error="errors.endDate"
:min="form.startDate"
:show-pills="!isSingleDay"
/>
<!-- Duration & projected balance -->
<div v-if="preview" class="flex flex-col gap-1 rounded-lg bg-neutral-50 p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-neutral-600">{{ $t('absences.form.duration') }}</span>
<span class="font-semibold text-neutral-900">{{ formatDays(preview.countedDays) }}</span>
</div>
<div v-if="preview.projectedAvailable !== null" class="flex items-center justify-between border-t border-neutral-200 pt-1 text-sm">
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAfterValidation') }}</span>
<span :class="preview.projectedAvailable < 0 ? 'font-semibold text-amber-600' : 'text-neutral-900'">
{{ formatDays(preview.projectedAvailable) }}
</span>
</div>
</div>
<div
v-if="preview && preview.projectedAvailable !== null && preview.projectedAvailable < 0"
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-700"
>
{{ $t('absences.form.negativeWarning') }}
</div>
<!-- Step 4 justification (only when the policy requires it) -->
<MalioInputUpload
v-if="showJustification"
:model-value="form.file?.name ?? null"
:label="`${$t('absences.form.justification')} *`"
accept="application/pdf,image/png,image/jpeg,image/webp"
:error="errors.justification"
@file-selected="onFileSelected"
/>
<!-- Comment (optional) -->
<div v-if="showComment" class="flex items-start gap-2">
<span class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600">
{{ initials }}
</span>
<MalioInputTextArea
v-model="form.reason"
group-class="flex-1"
:placeholder="$t('absences.form.commentPlaceholder')"
/>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton
:label="$t('absences.form.submit')"
:disabled="submitting"
@click="submit"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
policies: AbsencePolicy[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': []
}>()
const { t } = useI18n()
const { formatDays, formatDate } = useAbsenceHelpers()
const service = useAbsenceService()
const auth = useAuthStore()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
type FormState = {
type: AbsenceType | null
// ISO date strings "YYYY-MM-DD" (lexicographic order == chronological order).
startDate: string | null
startHalf: HalfDay | null
endDate: string | null
endHalf: HalfDay | null
reason: string
file: File | null
}
const form = reactive<FormState>({
type: null,
startDate: null,
startHalf: null,
endDate: null,
endHalf: null,
reason: '',
file: null,
})
const errors = reactive<{ type: string; startDate: string; endDate: string; justification: string }>({
type: '',
startDate: '',
endDate: '',
justification: '',
})
const serverError = ref('')
const preview = ref<AbsencePreviewResult | null>(null)
const submitting = ref(false)
const typeOptions = computed(() =>
props.policies
.filter(p => p.active)
.map(p => ({ label: p.label, value: p.type })),
)
const selectedPolicy = computed(() => props.policies.find(p => p.type === form.type) ?? null)
const justificationRequired = computed(() => selectedPolicy.value?.justificationRequired ?? false)
const showDates = computed(() => form.type !== null)
const showEnd = computed(() => form.startDate !== null)
const showJustification = computed(() => form.type !== null && justificationRequired.value)
const showComment = computed(() => form.startDate !== null)
const isSingleDay = computed(() =>
form.startDate !== null
&& form.endDate !== null
&& form.startDate === form.endDate,
)
const startDateLabel = computed(() => formatDate(form.startDate))
const initials = computed(() => {
const name = auth.user?.username ?? ''
return name.slice(0, 2).toUpperCase() || '?'
})
function onFileSelected(file: File) {
form.file = file
errors.justification = ''
}
function buildPayload() {
// On a single-day request the end half-day mirrors the start.
const endHalf = isSingleDay.value ? form.startHalf : form.endHalf
return {
type: form.type as AbsenceType,
startDate: form.startDate as string,
endDate: form.endDate as string,
startHalfDay: form.startHalf,
endHalfDay: endHalf,
reason: form.reason || null,
}
}
function validate(): boolean {
errors.type = form.type ? '' : t('absences.form.errors.typeRequired')
errors.startDate = form.startDate ? '' : t('absences.form.errors.startRequired')
if (form.endDate === null) {
errors.endDate = t('absences.form.errors.endRequired')
} else if (form.startDate && form.endDate < form.startDate) {
errors.endDate = t('absences.form.errors.endBeforeStart')
} else if (form.type && form.startDate && (preview.value?.countedDays ?? 0) <= 0) {
errors.endDate = t('absences.form.errors.zeroDays')
} else {
errors.endDate = ''
}
errors.justification = justificationRequired.value && !form.file
? t('absences.form.errors.justificationRequired')
: ''
return !errors.type && !errors.startDate && !errors.endDate && !errors.justification
}
// Clear field errors as soon as the user corrects them.
watch(() => form.type, (v) => { if (v) errors.type = '' })
watch(() => form.startDate, (v) => { if (v) errors.startDate = '' })
watch(() => [form.endDate, form.startDate], () => {
if (form.endDate && (!form.startDate || form.endDate >= form.startDate)) errors.endDate = ''
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => [form.type, form.startDate, form.endDate, form.startHalf, form.endHalf],
() => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!form.type || !form.startDate || !form.endDate) {
preview.value = null
return
}
debounceTimer = setTimeout(async () => {
try {
preview.value = await service.preview(buildPayload())
} catch {
preview.value = null
}
}, 300)
},
{ deep: true },
)
async function submit() {
serverError.value = ''
if (!validate()) return
submitting.value = true
try {
const created = await service.create(buildPayload())
if (form.file) {
await service.uploadJustification(created.id, form.file)
}
emit('created')
open.value = false
resetForm()
} catch (e) {
serverError.value = (e instanceof Error && e.message) ? e.message : t('absences.form.serverError')
} finally {
submitting.value = false
}
}
function resetForm() {
form.type = null
form.startDate = null
form.startHalf = null
form.endDate = null
form.endHalf = null
form.reason = ''
form.file = null
errors.type = ''
errors.startDate = ''
errors.endDate = ''
errors.justification = ''
serverError.value = ''
preview.value = null
}
watch(open, (v) => {
if (v) resetForm()
})
</script>

View File

@@ -0,0 +1,137 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<div>
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
</div>
</template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
<MalioSelect
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
:options="contractOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioInputText
v-model="form.workTimeRatio"
:label="$t('absences.admin.employees.fields.workTimeRatio')"
input-class="w-full"
/>
<MalioInputText
v-model="form.annualLeaveDays"
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
input-class="w-full"
/>
<MalioInputText
v-model="form.referencePeriodStart"
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
input-class="w-full"
/>
<MalioInputText
v-model="form.initialLeaveBalance"
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
input-class="w-full"
/>
<div class="col-span-full mt-2 flex justify-end">
<MalioButton
:label="$t('absences.admin.employees.drawer.save')"
button-class="w-auto px-6"
:disabled="submitting"
@click="save"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { ContractType, UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
const props = defineProps<{
modelValue: boolean
user: UserData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const { t } = useI18n()
const { update } = useUserService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const submitting = ref(false)
const contractOptions = [
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
]
const form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '0',
})
function hydrate(u: UserData | null) {
if (!u) return
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
form.contractType = u.contractType ?? null
form.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
}
watch(() => props.modelValue, (isOpen) => {
if (isOpen) hydrate(props.user)
})
async function save() {
if (!props.user) return
submitting.value = true
try {
await update(props.user.id, {
isEmployee: true,
hireDate: form.hireDate || null,
endDate: form.endDate || null,
contractType: form.contractType,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
})
emit('saved')
open.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="flex flex-col gap-4 pt-2">
<div>
<h2 class="text-lg font-semibold text-neutral-900">{{ $t('absences.policies.title') }}</h2>
<p class="text-sm text-neutral-500">{{ $t('absences.policies.subtitle') }}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b border-neutral-200 text-left text-neutral-500">
<th class="py-2 pr-3">{{ $t('absences.policies.type') }}</th>
<th class="py-2 px-2">{{ $t('absences.policies.daysPerYear') }}</th>
<th class="py-2 px-2">{{ $t('absences.policies.daysPerEvent') }}</th>
<th class="py-2 px-2">{{ $t('absences.policies.noticeDays') }}</th>
<th class="py-2 px-2 text-center">{{ $t('absences.policies.justificationRequired') }}</th>
<th class="py-2 px-2 text-center">{{ $t('absences.policies.countWorkingDaysOnly') }}</th>
<th class="py-2 px-2 text-center">{{ $t('absences.policies.active') }}</th>
<th class="py-2 pl-2" />
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id" class="border-b border-neutral-100">
<td class="py-2 pr-3 font-medium text-neutral-800">{{ row.label }}</td>
<td class="py-2 px-2">
<input v-model.number="row.daysPerYear" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
</td>
<td class="py-2 px-2">
<input v-model.number="row.daysPerEvent" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
</td>
<td class="py-2 px-2">
<input v-model.number="row.noticeDays" type="number" class="w-16 rounded border border-neutral-300 px-2 py-1">
</td>
<td class="py-2 px-2 text-center">
<input v-model="row.justificationRequired" type="checkbox" class="h-4 w-4">
</td>
<td class="py-2 px-2 text-center">
<input v-model="row.countWorkingDaysOnly" type="checkbox" class="h-4 w-4">
</td>
<td class="py-2 px-2 text-center">
<input v-model="row.active" type="checkbox" class="h-4 w-4">
</td>
<td class="py-2 pl-2 text-right">
<MalioButton :label="$t('absences.policies.save')" variant="secondary" @click="save(row)" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import type { AbsencePolicy } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([])
async function load() {
rows.value = await service.getPolicies()
}
async function save(row: AbsencePolicy) {
await service.updatePolicy(row.id, {
daysPerYear: row.daysPerYear === null || Number.isNaN(row.daysPerYear) ? null : Number(row.daysPerYear),
daysPerEvent: row.daysPerEvent === null || Number.isNaN(row.daysPerEvent) ? null : Number(row.daysPerEvent),
noticeDays: Number(row.noticeDays),
justificationRequired: row.justificationRequired,
countWorkingDaysOnly: row.countWorkingDaysOnly,
active: row.active,
})
}
onMounted(load)
</script>

View File

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

View File

@@ -0,0 +1,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

@@ -1,140 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un statut"
@click="openCreate"
/>
</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

@@ -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,272 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow') }}</h2>
</template>
<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"
/>
<MalioSelect
v-model="s.category"
:options="categoryOptions"
label="Catégorie"
group-class="w-48 shrink-0"
/>
<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 { STATUS_CATEGORY_COLOR } 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
})
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
if (!prev) return
cats.forEach((cat, i) => {
const s = form.statuses[i]
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
}
})
})
function addStatus() {
form.statuses.push({
label: '',
color: STATUS_CATEGORY_COLOR.todo,
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

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

View File

@@ -1,328 +0,0 @@
<template>
<div>
<!-- Trigger button -->
<MalioButton
variant="tertiary"
icon-name="mdi:ticket-outline"
icon-position="left"
button-class="w-auto px-3 sm:px-4 shrink-0"
@click="open"
>
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span
v-if="totalCount > 0"
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
>
{{ totalCount }}
</span>
</MalioButton>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
<Transition name="ct-panel" appear>
<div class="fixed inset-0 z-50 flex justify-end">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Slide panel -->
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Filters -->
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
<option value="new">{{ $t('clientTicket.status.new') }}</option>
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
<option value="done">{{ $t('clientTicket.status.done') }}</option>
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
</select>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
{{ $t('common.loading') }}
</div>
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="space-y-2">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="rounded-lg border border-neutral-200 bg-white"
>
<!-- Ticket row -->
<div
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
@click="toggleExpand(ticket.id)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="typeBadgeClass(ticket.type)"
>
{{ $t(`clientTicket.type.${ticket.type}`) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<MalioButtonIcon
icon="mdi:swap-horizontal"
:aria-label="$t('clientTicket.changeStatus')"
variant="ghost"
icon-size="16"
@click.stop="openStatusChange(ticket)"
/>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18"
class="text-neutral-400"
/>
</div>
</div>
<!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
<div v-if="ticket.url" class="mt-2">
<a
:href="ticket.url"
target="_blank"
class="text-xs text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
{{ ticket.statusComment }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="ct-modal" appear>
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="statusModalOpen = false"
/>
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
CT-{{ String(statusTarget.number).padStart(3, '0') }} {{ statusTarget.title }}
</p>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
<select
v-model="newStatus"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option :value="null" disabled></option>
<option
v-for="s in availableStatusTransitions"
:key="s.value"
:value="s.value"
>
{{ s.label }}
</option>
</select>
</div>
<div v-if="newStatus === 'rejected'" class="mt-4">
<MalioInputTextArea
v-model="statusComment"
:label="$t('clientTicket.statusComment')"
:size="3"
/>
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
{{ $t('clientTicket.rejectionRequired') }}
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="statusModalOpen = false"
/>
<MalioButton
label="Confirmer"
button-class="w-auto px-6"
:disabled="isUpdatingStatus"
@click="confirmStatusChange"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
projectId: number
projectName: string
}>()
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
const tickets = ref<ClientTicket[]>([])
const filterStatus = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const totalCount = computed(() =>
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
)
const filteredTickets = computed(() => {
if (!filterStatus.value) return tickets.value
return tickets.value.filter(t => t.status === filterStatus.value)
})
// Status change
const statusModalOpen = ref(false)
const statusTarget = ref<ClientTicket | null>(null)
const newStatus = ref<string | null>(null)
const statusComment = ref('')
const rejectionError = ref(false)
const isUpdatingStatus = ref(false)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
async function loadTickets() {
isLoading.value = true
try {
tickets.value = await clientTicketService.getAll({ project: props.projectId })
} finally {
isLoading.value = false
}
}
function open() {
isOpen.value = true
loadTickets()
}
function close() {
isOpen.value = false
expandedId.value = null
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.value = true
}
async function confirmStatusChange() {
if (!statusTarget.value || !newStatus.value) return
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
rejectionError.value = true
return
}
isUpdatingStatus.value = true
try {
await clientTicketService.updateStatus(statusTarget.value.id, {
status: newStatus.value as ClientTicketStatus,
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
})
statusModalOpen.value = false
await loadTickets()
} finally {
isUpdatingStatus.value = false
}
}
</script>
<style scoped>
.ct-panel-enter-active,
.ct-panel-leave-active {
transition: opacity 0.2s ease;
}
.ct-panel-enter-active > div:last-child,
.ct-panel-leave-active > div:last-child {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-panel-enter-from,
.ct-panel-leave-to {
opacity: 0;
}
.ct-panel-enter-from > div:last-child,
.ct-panel-leave-to > div:last-child {
transform: translateX(100%);
}
.ct-modal-enter-active,
.ct-modal-leave-active {
transition: opacity 0.15s ease;
}
.ct-modal-enter-from,
.ct-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
const props = defineProps<{
/** Ouverture de la visionneuse. */
modelValue: boolean
/** Nom du fichier affiché dans la barre. */
filename: string
/** Type MIME — détermine le rendu (image vs PDF). */
mimeType: string
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
url: string | null
/** Téléchargement en cours du contenu. */
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
download: []
}>()
const { t } = useI18n()
const isImage = computed(() => props.mimeType.startsWith('image/'))
const isPdf = computed(() => props.mimeType === 'application/pdf')
function close(): void {
emit('update:modelValue', false)
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
watch(
() => props.modelValue,
(open) => {
if (open) {
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
},
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="mail-preview" appear>
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
<!-- Barre supérieure -->
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
<div class="flex min-w-0 items-center gap-2">
<Icon
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
size="18"
class="flex-shrink-0 text-white/70"
/>
<span class="truncate text-sm font-medium">{{ filename }}</span>
</div>
<div class="flex flex-shrink-0 items-center gap-1">
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
@click="emit('download')"
>
<Icon name="material-symbols:download" size="18" />
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
</button>
<button
type="button"
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
:aria-label="t('mail.preview.close')"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Contenu -->
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
</div>
<img
v-else-if="isImage && url"
:src="url"
:alt="filename"
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
>
<iframe
v-else-if="isPdf && url"
:src="url"
:title="filename"
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
/>
<div v-else class="flex flex-col items-center gap-3 text-white/70">
<Icon name="material-symbols:visibility-off-outline" size="32" />
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mail-preview-enter-active,
.mail-preview-leave-active {
transition: opacity 0.2s ease;
}
.mail-preview-enter-from,
.mail-preview-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,144 @@
<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 { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useAuthStore } from '~/stores/auth'
const props = defineProps<{
modelValue: boolean
messageId: number
messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [task: Task]
}>()
const { t } = useI18n()
const auth = useAuthStore()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const userService = useUserService()
const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const assigneeId = ref<number | null>(null)
const statusId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)
const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
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 userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
const statusOptions = computed(() =>
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
)
onMounted(async () => {
const [projs, us] = await Promise.all([
projectService.getAll({ archived: false }),
userService.getAll(),
])
projects.value = projs
users.value = us
})
watch(projectId, async (pid) => {
taskGroupId.value = null
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
groups.value = []
if (!pid) return
loadingGroups.value = true
try {
groups.value = await taskGroupService.getByProject(pid)
} finally {
loadingGroups.value = false
}
})
watch(() => props.modelValue, (open) => {
if (open) {
projectId.value = null
taskGroupId.value = null
statusId.value = null
assigneeId.value = auth.user?.id ?? null
touchedProject.value = false
}
})
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,
assigneeId: assigneeId.value ?? undefined,
statusId: statusId.value ?? undefined,
})
emit('created', task)
close()
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<AppModal
:model-value="modelValue"
width="lg"
:title="t('mail.createTaskModal.title')"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-5">
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
</div>
<div>
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
</div>
<div v-if="projectId">
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
</div>
<div v-if="projectId">
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
</div>
<div>
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
</div>
</div>
<template #footer>
<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" />
</template>
</AppModal>
</template>

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')"
group-class="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,278 @@
<script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } 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 })
})
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
function isImage(mime: string): boolean {
return mime.startsWith('image/')
}
function isPdf(mime: string): boolean {
return mime === 'application/pdf'
}
function isPreviewable(mime: string): boolean {
return isImage(mime) || isPdf(mime)
}
function attachmentIcon(mime: string): string {
if (isImage(mime)) return 'material-symbols:image-outline'
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
return 'material-symbols:attach-file'
}
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewAtt = ref<MailAttachmentDto | null>(null)
const previewUrl = ref<string | null>(null)
let previewBlob: Blob | null = null
function revokePreview(): void {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
previewBlob = null
}
watch(
() => props.detail?.header.id,
() => {
showImages.value = false
previewOpen.value = false
revokePreview()
},
)
watch(previewOpen, (open) => {
if (!open) revokePreview()
})
onBeforeUnmount(revokePreview)
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
if (!isPreviewable(att.mimeType)) {
await handleDownload(att.downloadId, att.filename)
return
}
previewAtt.value = att
previewUrl.value = null
previewLoading.value = true
previewOpen.value = true
try {
const { data } = await mailService.downloadAttachment(att.downloadId)
previewBlob = data
previewUrl.value = URL.createObjectURL(data)
} catch {
// useApi affiche déjà le toast — on referme la visionneuse.
previewOpen.value = false
} finally {
previewLoading.value = false
}
}
function downloadFromPreview(): void {
const att = previewAtt.value
if (!att) return
if (previewBlob) {
triggerBlobDownload(previewBlob, att.filename)
} else {
void handleDownload(att.downloadId, att.filename)
}
}
function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
async function handleDownload(downloadId: string, filename: string): Promise<void> {
try {
const { data } = await mailService.downloadAttachment(downloadId)
triggerBlobDownload(data, filename)
} 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="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
@click="handleAttachmentClick(att)"
>
<Icon :name="attachmentIcon(att.mimeType)" 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>
<Icon
v-if="isPreviewable(att.mimeType)"
name="material-symbols:visibility-outline"
size="13"
class="flex-shrink-0 text-neutral-400"
/>
</button>
</div>
</div>
<MailAttachmentPreview
v-if="previewAtt"
v-model="previewOpen"
:filename="previewAtt.filename"
:mime-type="previewAtt.mimeType"
:url="previewUrl"
:loading="previewLoading"
@download="downloadFromPreview"
/>
</template>
</div>
</template>

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

@@ -106,19 +106,7 @@ function handleClick(notif: Notification) {
if (!notif.isRead) { if (!notif.isRead) {
markAsRead(notif.id) markAsRead(notif.id)
} }
isOpen.value = false
if (notif.relatedTicket) {
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
} }
async function handleMarkAllRead() { async function handleMarkAllRead() {

View File

@@ -1,14 +1,17 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
</template>
<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="codeProxy"
label="Code" label="Code"
input-class="w-full uppercase" input-class="w-full"
:max-length="10"
:disabled="isEditing" :disabled="isEditing"
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''" :error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
@blur="touched.code = true" @blur="touched.code = true"
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
/> />
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
@@ -27,7 +30,7 @@
:options="clientOptions" :options="clientOptions"
label="Client" label="Client"
empty-option-label="Aucun client" empty-option-label="Aucun client"
min-width="w-full" group-class="w-full"
/> />
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />
@@ -39,7 +42,7 @@
:options="giteaRepoOptions" :options="giteaRepoOptions"
label="Dépôt Gitea" label="Dépôt Gitea"
empty-option-label="Aucun dépôt" empty-option-label="Aucun dépôt"
min-width="w-full" group-class="w-full"
/> />
</div> </div>
@@ -49,7 +52,7 @@
:options="bookstackShelfOptions" :options="bookstackShelfOptions"
label="Étagère BookStack" label="Étagère BookStack"
empty-option-label="Aucune étagère" empty-option-label="Aucune étagère"
min-width="w-full" group-class="w-full"
/> />
</div> </div>
@@ -87,10 +90,35 @@
</MalioButton> </MalioButton>
</div> </div>
<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 <ConfirmDeleteProjectModal
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<ProjectWorkflowSwitchModal
v-if="props.project"
v-model="switchModalOpen"
:project="props.project"
@switched="onWorkflowSwitched"
/>
</MalioDrawer> </MalioDrawer>
</template> </template>
@@ -122,6 +150,15 @@ 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 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[]>([])
@@ -152,6 +189,17 @@ const touched = reactive({
name: false, name: false,
}) })
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
// form.code en minuscules et bloquait la création.
const codeProxy = computed({
get: () => form.code,
set: (value: string) => {
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
},
})
const clientOptions = computed(() => const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id })) props.clients.map(c => ({ label: c.name, value: c.id }))
) )
@@ -188,7 +236,7 @@ async function handleSubmit() {
touched.name = true touched.name = true
touched.code = true touched.code = true
if (!form.name.trim()) return if (!form.name.trim()) return
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -220,7 +268,7 @@ async function handleSubmit() {
if (isEditing.value && props.project) { if (isEditing.value && props.project) {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
payload.code = form.code.trim() payload.code = form.code
await create(payload) await create(payload)
} }

View File

@@ -36,7 +36,7 @@
/> />
</template> </template>
<template #cell-description="{ item }"> <template #cell-description="{ item }">
{{ item.description ?? '—' }} {{ stripRichText(item.description) || '—' }}
</template> </template>
<template #actions="{ item }"> <template #actions="{ item }">
<MalioButton <MalioButton
@@ -71,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=""
group-class="!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

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{
statuses: TaskStatus[]
x: number
y: number
}>()
const emit = defineEmits<{
pick: [status: TaskStatus]
cancel: []
}>()
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
<div
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
:style="{ left: x + 'px', top: y + 'px' }"
>
<button
v-for="s in statuses"
:key="s.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
@click="emit('pick', s)"
>
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
{{ s.label }}
</button>
</div>
</Teleport>
</template>

View File

@@ -14,24 +14,32 @@
</span> </span>
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1"> <div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
<!-- Bulk status --> <!-- Bulk status (scoped to single project's workflow) -->
<MalioSelect <MalioSelect
v-if="!isMultiProject"
:model-value="null" :model-value="null"
:options="statusOptions" :options="statusOptions"
label="Status" label="Status"
empty-option-label="Status" empty-option-label="Status"
min-width="!w-32" group-class="!w-32"
text-field="text-xs" text-field="text-xs"
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)" @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 --> <!-- Bulk user -->
<MalioSelect <MalioSelect
:model-value="null" :model-value="null"
:options="userOptions" :options="userOptions"
label="User" label="User"
empty-option-label="User" empty-option-label="User"
min-width="!w-32" group-class="!w-32"
text-field="text-xs" text-field="text-xs"
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
@@ -42,7 +50,7 @@
:options="priorityOptions" :options="priorityOptions"
label="Priorité" label="Priorité"
empty-option-label="Priorité" empty-option-label="Priorité"
min-width="!w-32" group-class="!w-32"
text-field="text-xs" text-field="text-xs"
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
@@ -53,7 +61,7 @@
:options="effortOptions" :options="effortOptions"
label="Effort" label="Effort"
empty-option-label="Effort" empty-option-label="Effort"
min-width="!w-32" group-class="!w-32"
text-field="text-xs" text-field="text-xs"
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
@@ -65,7 +73,7 @@
:options="groupOptions" :options="groupOptions"
label="Groupe" label="Groupe"
empty-option-label="Groupe" empty-option-label="Groupe"
min-width="!w-32" group-class="!w-32"
text-field="text-xs" text-field="text-xs"
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
@@ -85,13 +93,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
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 type { Project } from '~/services/dto/project'
const props = defineProps<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
totalCount: number totalCount: number
allSelected: boolean allSelected: boolean
@@ -101,7 +111,12 @@ const props = defineProps<{
priorities: TaskPriority[] priorities: TaskPriority[]
efforts: TaskEffort[] efforts: TaskEffort[]
groups: TaskGroup[] groups: TaskGroup[]
}>() selectedTasks?: Task[]
projects?: Project[]
}>(), {
selectedTasks: () => [],
projects: () => [],
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle-all'): void (e: 'toggle-all'): void
@@ -110,23 +125,42 @@ const emit = defineEmits<{
(e: 'bulk-delete'): void (e: 'bulk-delete'): void
}>() }>()
const statusOptions = computed(() => const distinctProjectIds = computed(() => {
props.statuses.map(s => ({ label: s.label, value: s.id })) 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(() => const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id })),
) )
const priorityOptions = computed(() => const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id })) props.priorities.map(p => ({ label: p.label, value: p.id })),
) )
const effortOptions = computed(() => const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id })) props.efforts.map(e => ({ label: e.label, value: e.id })),
) )
const groupOptions = computed(() => const groupOptions = computed(() =>
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })) props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
) )
</script> </script>

View File

@@ -20,14 +20,8 @@
name="mdi:flag-variant" name="mdi:flag-variant"
class="h-3.5 w-3.5 text-red-600" class="h-3.5 w-3.5 text-red-600"
/> />
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
</div> </div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4> <h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div> </div>
<MalioButtonIcon <MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" :icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
@@ -39,7 +33,14 @@
/> />
</div> </div>
<div class="mt-2 flex items-center gap-1.5"> <div class="mt-2 flex flex-wrap 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"
@@ -106,8 +107,10 @@ import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
showProjectColor?: boolean showProjectColor?: boolean
showStatusBadge?: boolean
}>(), { }>(), {
showProjectColor: false, showProjectColor: false,
showStatusBadge: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

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

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -70,7 +70,7 @@
v-model="branchForm.type" v-model="branchForm.type"
:options="typeOptions" :options="typeOptions"
:label="$t('gitea.branch.type')" :label="$t('gitea.branch.type')"
min-width="w-full" group-class="w-full"
/> />
<MalioInputText <MalioInputText
v-model="branchForm.baseBranch" v-model="branchForm.baseBranch"

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
</template>
<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 +11,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" />

View File

@@ -35,41 +35,24 @@
@click="close" @click="close"
/> />
</div> </div>
<!-- Client ticket link -->
<div
v-if="isEditing && task?.clientTicket"
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
>
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
<span class="text-sm font-medium text-blue-700">
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
</span>
<span
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
:class="ticketStatusClass(task.clientTicket.status)"
>
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
</span>
</div>
</div> </div>
<!-- Body --> <!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6"> <form @submit.prevent="handleSubmit" class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4"> <div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6"> <nav class="flex gap-6">
<button <button
v-for="tab in ['details', 'planning']" v-for="tab in availableTabs"
:key="tab" :key="tab"
type="button" type="button"
class="px-1 pb-3 text-sm font-semibold transition" class="px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab :class="activeTab === tab
? 'border-b-2 border-primary-500 text-primary-500' ? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'" : 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab as 'details' | 'planning'" @click="activeTab = tab as 'details' | 'planning' | 'mails'"
> >
{{ $t(`tasks.${tab}Tab`) }} {{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
</button> </button>
</nav> </nav>
</div> </div>
@@ -91,7 +74,7 @@
:options="projectOptions" :options="projectOptions"
label="Projet *" label="Projet *"
empty-option-label="Sélectionner un projet" empty-option-label="Sélectionner un projet"
min-width="w-full" group-class="w-full"
/> />
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500"> <p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis Le projet est requis
@@ -105,43 +88,35 @@
:options="statusOptions" :options="statusOptions"
label="Statut" label="Statut"
empty-option-label="Aucun statut" empty-option-label="Aucun statut"
min-width="w-full" group-class="w-full"
/> />
<MalioSelect <MalioSelect
v-model="form.assigneeId" v-model="form.assigneeId"
:options="userOptions" :options="userOptions"
label="User" label="User"
empty-option-label="Aucun utilisateur" empty-option-label="Aucun utilisateur"
min-width="w-full" group-class="w-full"
/> />
<MalioSelect <MalioSelect
v-model="form.effortId" v-model="form.effortId"
:options="effortOptions" :options="effortOptions"
label="Effort" label="Effort"
empty-option-label="Aucun effort" empty-option-label="Aucun effort"
min-width="w-full" group-class="w-full"
/> />
<MalioSelect <MalioSelect
v-model="form.priorityId" v-model="form.priorityId"
:options="priorityOptions" :options="priorityOptions"
label="Priorité" label="Priorité"
empty-option-label="Aucune priorité" empty-option-label="Aucune priorité"
min-width="w-full" group-class="w-full"
/> />
<MalioSelect <MalioSelect
v-model="form.groupId" v-model="form.groupId"
:options="groupOptions" :options="groupOptions"
label="Groupe" label="Groupe"
empty-option-label="Aucun groupe" empty-option-label="Aucun groupe"
min-width="w-full" group-class="w-full"
/>
<MalioSelect
v-if="clientTicketOptions.length"
v-model="form.clientTicketId"
:options="clientTicketOptions"
label="Ticket client"
empty-option-label="Aucun ticket client"
min-width="w-full"
/> />
</div> </div>
@@ -196,13 +171,10 @@
<!-- 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="5" min-height="180px"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
/> />
</div> </div>
@@ -436,51 +408,102 @@
</div> </div>
</div> </div>
<!-- Footer --> <!-- Onglet Mails -->
<div <div v-show="activeTab === 'mails'" class="space-y-4">
class="mt-6 flex items-center border-t border-neutral-100 pt-5" <!-- Chargement -->
:class="isEditing ? 'justify-between' : 'justify-end'" <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>
</div>
</form>
<!-- Footer -->
<div
class="shrink-0 flex items-center border-t border-neutral-100 bg-white px-4 py-4 sm:px-8 sm:py-5"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
<MalioButton
v-if="isEditing"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
/>
<div class="flex gap-3">
<MalioButton <MalioButton
v-if="isEditing" v-if="canArchive"
variant="danger" variant="tertiary"
label="Supprimer" :label="$t('archive.archiveButton')"
button-class="w-auto px-4" button-class="w-auto px-4"
:disabled="isSubmitting" :disabled="isSubmitting"
@click="confirmDeleteOpen = true" @click="handleArchive"
/>
<MalioButton
v-if="canUnarchive"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/> />
<div class="flex gap-3">
<MalioButton
v-if="canArchive"
variant="tertiary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleArchive"
/>
<MalioButton
v-if="canUnarchive"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</div> </div>
</form> </div>
<ConfirmDeleteTaskModal <ConfirmDeleteTaskModal
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@@ -501,10 +524,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task' import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue' import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
@@ -516,6 +537,8 @@ import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences' import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/services/dto/project' 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
@@ -548,7 +571,13 @@ 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'>('details') const activeTab = ref<'details' | 'planning' | 'mails'>('details')
// ─── Onglet Mails ─────────────────────────────────────────────────────────
const mailService = useMailService()
const linkedMails = ref<MailMessageHeaderDto[]>([])
const mailsLoading = ref(false)
const giteaUrl = ref('') const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService() const { getSettings: getGiteaSettings } = useGiteaService()
@@ -571,7 +600,6 @@ const form = reactive({
collaboratorIds: [] as number[], collaboratorIds: [] as number[],
groupId: null as number | null, groupId: null as number | null,
tagIds: [] as number[], tagIds: [] as number[],
clientTicketId: null as number | null,
projectId: null as number | null, projectId: null as number | null,
scheduledStart: '', scheduledStart: '',
scheduledEnd: '', scheduledEnd: '',
@@ -595,10 +623,27 @@ const touched = reactive({
project: false, project: false,
}) })
const statusOptions = computed(() => const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
props.statuses.map(s => ({ label: s.label, value: s.id }))
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
) )
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const statusOptions = computed(() => {
const project = props.projects?.find(p => p.id === resolvedProjectId.value)
const wfStatuses = project?.workflow?.statuses ?? props.statuses
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
const current = props.task?.status
if (current && !wfStatuses.some(s => s.id === current.id)) {
opts.unshift({ label: current.label, value: current.id })
}
return opts
})
const effortOptions = computed(() => const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id })) props.efforts.map(e => ({ label: e.label, value: e.id }))
) )
@@ -631,16 +676,6 @@ const groupOptions = computed(() => {
return filtered.map(g => ({ label: g.title, value: g.id })) 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(() => {
if (!isEditing.value || !props.task) return false if (!isEditing.value || !props.task) return false
if (props.task.archived) return false if (props.task.archived) return false
@@ -694,7 +729,6 @@ function populateForm(task: Task | null) {
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? [] 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.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : '' form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : '' form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
form.deadline = task.deadline ? task.deadline.slice(0, 10) : '' form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
@@ -741,7 +775,6 @@ function populateForm(task: Task | null) {
form.collaboratorIds = [] form.collaboratorIds = []
form.groupId = null form.groupId = null
form.tagIds = [] form.tagIds = []
form.clientTicketId = null
form.projectId = null form.projectId = null
form.scheduledStart = '' form.scheduledStart = ''
form.scheduledEnd = '' form.scheduledEnd = ''
@@ -768,17 +801,8 @@ watch(() => props.modelValue, async (open) => {
activeTab.value = 'details' activeTab.value = 'details'
confirmDeleteDocOpen.value = false confirmDeleteDocOpen.value = false
documentToDelete.value = null documentToDelete.value = null
linkedMails.value = []
populateForm(props.task) populateForm(props.task)
const pid = resolvedProjectId.value
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) { if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try { try {
const settings = await getGiteaSettings() const settings = await getGiteaSettings()
@@ -798,44 +822,50 @@ 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 { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService() const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
const { t } = useI18n() const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([]) // Reset group when project changes in create mode
const clientTicketOptions = computed(() => watch(() => form.projectId, () => {
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 if (!showProjectSelect.value) return
form.groupId = null form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
}) })
const authStore = useAuthStore() const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
function ticketStatusClass(status: string): string { const availableTabs = computed(() => {
switch (status) { const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
case 'new': return 'bg-blue-100 text-blue-700' if (isEditing.value) base.push('mails')
case 'in_progress': return 'bg-yellow-100 text-yellow-700' return base
case 'done': return 'bg-green-100 text-green-700' })
case 'rejected': return 'bg-red-100 text-red-700'
default: return 'bg-neutral-100 text-neutral-700' async function loadLinkedMails(): Promise<void> {
if (!props.task) 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()
}
})
function formatMailDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', {
day: '2-digit',
month: 'short',
})
}
const localDocuments = ref<TaskDocument[]>([]) const localDocuments = ref<TaskDocument[]>([])
const previewDoc = ref<TaskDocument | null>(null) const previewDoc = ref<TaskDocument | null>(null)
@@ -955,7 +985,6 @@ async function handleSubmit() {
group: form.groupId ? `/api/task_groups/${form.groupId}` : null, group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${resolvedProjectId.value}`, project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`), tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
scheduledStart: form.scheduledStart || null, scheduledStart: form.scheduledStart || null,
scheduledEnd: form.scheduledEnd || null, scheduledEnd: form.scheduledEnd || null,
deadline: form.deadline || null, deadline: form.deadline || null,

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,122 +0,0 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
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">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</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,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
</template>
<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 +14,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>
@@ -61,7 +61,7 @@
v-model="form.userId" v-model="form.userId"
:options="userOptions" :options="userOptions"
label="Utilisateur" label="Utilisateur"
min-width="w-full" group-class="w-full"
/> />
<MalioSelect <MalioSelect
@@ -69,7 +69,7 @@
:options="projectOptions" :options="projectOptions"
label="Projet" label="Projet"
empty-option-label=" Aucun " empty-option-label=" Aucun "
min-width="w-full" group-class="w-full"
/> />
<div> <div>

View File

@@ -33,8 +33,8 @@
</div> </div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500"> <div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span v-if="entry.project">{{ entry.project.name }}</span> <span v-if="entry.project">{{ entry.project.name }}</span>
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span> <span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
<span v-if="entry.description" class="truncate">{{ entry.description }}</span> <span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
</div> </div>
</div> </div>
@@ -68,6 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]

View File

@@ -9,6 +9,7 @@
v-for="day in days" v-for="day in days"
:key="'header-' + day.dateStr" :key="'header-' + day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center" class="flex-1 border-r border-neutral-100 py-2 text-center"
:class="{ 'bg-orange-50': day.holiday }"
> >
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'"> <div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }} {{ day.dayNum }}
@@ -16,6 +17,14 @@
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'"> <div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }} {{ day.label }}
</div> </div>
<div
v-if="day.holiday"
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
:title="day.holiday"
>
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
<span class="truncate">{{ day.holiday }}</span>
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div> <div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div> </div>
</div> </div>
@@ -40,6 +49,7 @@
:key="day.dateStr" :key="day.dateStr"
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }" :ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
class="relative flex-1 border-r border-neutral-100" class="relative flex-1 border-r border-neutral-100"
:class="{ 'bg-orange-50': day.holiday }"
@click="onClickGrid($event, day)" @click="onClickGrid($event, day)"
@contextmenu.prevent="onContextMenuGrid($event, day)" @contextmenu.prevent="onContextMenuGrid($event, day)"
> >
@@ -141,8 +151,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { useAbsenceService } from '~/services/absences'
const { t } = useI18n() const { t } = useI18n()
const absenceService = useAbsenceService()
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]
@@ -209,6 +221,23 @@ onMounted(() => {
}) })
}) })
// --- Public holidays (computed server-side, shared with the absence calendar) ---
const holidays = ref<Record<string, string>>({})
async function loadHolidays() {
const count = props.viewMode === 'week' ? 7 : 1
const start = new Date(props.startDate)
const end = new Date(start)
end.setDate(end.getDate() + count - 1)
try {
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
} catch {
holidays.value = {}
}
}
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
// --- Days computation --- // --- Days computation ---
const days = computed(() => { const days = computed(() => {
const count = props.viewMode === 'week' ? 7 : 1 const count = props.viewMode === 'week' ? 7 : 1
@@ -231,6 +260,7 @@ const days = computed(() => {
dateStr, dateStr,
dayNum: d.getDate(), dayNum: d.getDate(),
label: dayLabels[d.getDay()], label: dayLabels[d.getDay()],
holiday: holidays.value[dateStr] ?? null,
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`, totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
}) })
} }

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg"> <MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
</template>
<div class="flex flex-col gap-6 p-4"> <div class="flex flex-col gap-6 p-4">
<!-- Period presets --> <!-- Period presets -->
<div> <div>
@@ -52,7 +55,7 @@
:label="$t('timeEntries.exportUsers')" :label="$t('timeEntries.exportUsers')"
:display-tag="true" :display-tag="true"
:display-select-all="true" :display-select-all="true"
min-width="!w-full" group-class="!w-full"
/> />
</div> </div>
@@ -63,7 +66,7 @@
:options="clientOptions" :options="clientOptions"
:label="$t('timeEntries.exportClient')" :label="$t('timeEntries.exportClient')"
:empty-option-label="$t('timeEntries.exportAllClients')" :empty-option-label="$t('timeEntries.exportAllClients')"
min-width="!w-full" group-class="!w-full"
/> />
</div> </div>
@@ -75,7 +78,7 @@
:label="$t('timeEntries.exportProjects')" :label="$t('timeEntries.exportProjects')"
:display-tag="true" :display-tag="true"
:display-select-all="true" :display-select-all="true"
min-width="!w-full" group-class="!w-full"
/> />
</div> </div>
@@ -87,7 +90,7 @@
:label="$t('timeEntries.exportTags')" :label="$t('timeEntries.exportTags')"
:display-tag="true" :display-tag="true"
:display-select-all="true" :display-select-all="true"
min-width="!w-full" group-class="!w-full"
/> />
</div> </div>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
/** Largeur max du panneau */
width?: 'sm' | 'md' | 'lg' | 'xl'
}>(), {
title: '',
width: 'md',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
function close(): void {
emit('update:modelValue', false)
}
</script>
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="app-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 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
:class="WIDTH_CLASS[width]"
>
<!-- Header (fixe) -->
<div class="flex shrink-0 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">
<slot name="title">{{ title }}</slot>
</h2>
<MalioButtonIcon
icon="mdi:close"
aria-label="Fermer"
variant="ghost"
icon-size="20"
@click="close"
/>
</div>
<!-- Body (scrollable) -->
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<slot />
</div>
<!-- Footer (sticky) -->
<div
v-if="$slots.footer"
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-modal-enter-active,
.app-modal-leave-active {
transition: opacity 0.2s ease;
}
.app-modal-enter-active > div:last-child,
.app-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.app-modal-enter-from,
.app-modal-leave-to {
opacity: 0;
}
.app-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
</style>

View File

@@ -13,6 +13,14 @@
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1> <h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
</div> </div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8"> <div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<MalioButtonIcon
icon="mdi:help-circle-outline"
aria-label="Centre d'aide"
variant="ghost"
icon-size="22"
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
@click="navigateTo('/help')"
/>
<MalioButtonIcon <MalioButtonIcon
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" :icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'" :aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"

View File

@@ -1,22 +1,45 @@
<template> <template>
<div> <div>
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p> <p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap items-center gap-3">
<button <button
v-for="color in colors" v-for="color in presets"
:key="color" :key="color"
type="button" type="button"
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110" class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'" :class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
:style="{ backgroundColor: color }" :style="{ backgroundColor: color }"
@click="emit('update:modelValue', color)" :aria-label="`Choisir la couleur ${color}`"
@click="select(color)"
/> />
<!-- Couleur personnalisée : input natif déguisé en pastille -->
<label
class="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border-2 transition-transform hover:scale-110"
:class="isCustom ? 'border-neutral-900 scale-110' : 'border-dashed border-neutral-400'"
:style="isCustom ? { backgroundColor: modelValue } : {}"
title="Couleur personnalisée"
>
<input
type="color"
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
:value="modelValue"
aria-label="Choisir une couleur personnalisée"
@input="select(($event.target as HTMLInputElement).value)"
>
<Icon
v-if="!isCustom"
name="mdi:plus"
class="text-neutral-500"
size="20"
/>
</label>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
}>() }>()
@@ -24,8 +47,26 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
}>() }>()
const colors = [ // Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
'#222783', '#26A69A', '#E91E63', '#4A90D9', // pour que les projets/tags existants restent associés à une pastille.
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043', const presets = [
'#222783', '#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043', '#EF4444',
'#F97316', '#F59E0B', '#22C55E', '#10B981', '#06B6D4',
'#3B82F6', '#8B5CF6', '#64748B',
] ]
const norm = (value: string): string => (value ?? '').toUpperCase()
function isSelected(color: string): boolean {
return norm(props.modelValue) === norm(color)
}
const isCustom = computed(
() => !!props.modelValue && !presets.some((c) => norm(c) === norm(props.modelValue)),
)
function select(value: string): void {
emit('update:modelValue', norm(value))
}
</script> </script>

View File

@@ -1,93 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="isProcessing"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
const props = defineProps<{
modelValue: boolean
statusLabel: string
taskCount: number
availableStatuses: TaskStatus[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', targetStatusId: number | null): void
}>()
const targetStatusId = ref<number | null>(null)
const isProcessing = ref(false)
const targetOptions = computed(() =>
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
)
watch(() => props.modelValue, (open) => {
if (open) {
targetStatusId.value = null
isProcessing.value = false
}
})
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
isProcessing.value = true
emit('confirm', targetStatusId.value)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<Teleport to="body">
<Transition name="md-preview" appear>
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="emit('update:modelValue', false)"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(80vh, 700px)"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h3 class="text-lg font-semibold text-slate-800">
{{ title }}
</h3>
<button
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
@click="emit('update:modelValue', false)"
>
<Icon name="heroicons:x-mark" class="size-5" />
</button>
</div>
<!-- Body -->
<div class="overflow-y-auto px-6 py-4">
<div
v-if="content"
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
v-html="renderedHtml"
/>
<p v-else class="text-sm italic text-slate-400">
Aucune description
</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { marked } from 'marked'
const props = defineProps<{
modelValue: boolean
content: string
title?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const renderedHtml = computed(() => {
if (!props.content) return ''
return marked.parse(props.content, { async: false }) as string
})
</script>
<style scoped>
.md-preview-enter-active,
.md-preview-leave-active {
transition: opacity 0.2s ease;
}
.md-preview-enter-from,
.md-preview-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap"
:class="variantClass"
>
<Icon v-if="icon" :name="icon" size="14" />
{{ label }}
</span>
</template>
<script setup lang="ts">
type Variant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
const props = withDefaults(defineProps<{
label: string
variant?: Variant
icon?: string
}>(), {
variant: 'neutral',
icon: '',
})
const VARIANT_CLASSES: Record<Variant, string> = {
neutral: 'bg-neutral-100 text-neutral-700',
info: 'bg-blue-100 text-blue-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-amber-100 text-amber-800',
danger: 'bg-red-100 text-red-800',
}
const variantClass = computed(() => VARIANT_CLASSES[props.variant])
</script>

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('users.editUser') : $t('users.addUser') }}</h2>
</template>
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit"> <form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText <MalioInputText
v-model="form.username" v-model="form.username"
@@ -35,37 +38,12 @@
</div> </div>
</div> </div>
<div class="mt-4"> <!-- RH / Absences -->
<MalioSelect <div class="mt-6 border-t border-neutral-200 pt-4">
v-model="form.clientId" <MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
label="Client" <p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
:options="clientOptions" Les informations RH (contrat, dates, CP) se gèrent dans Absences équipe onglet Employés.
placeholder="Aucun client" </p>
class="w-full"
@update:model-value="onClientChange"
/>
</div>
<div v-if="form.clientId !== null" class="mt-2">
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
<div class="mt-2 flex flex-col gap-2">
<label
v-for="project in filteredProjects"
:key="project.id"
class="flex items-center gap-2 text-sm text-neutral-700"
>
<input
v-model="form.allowedProjectIds"
type="checkbox"
:value="project.id"
class="rounded border-neutral-300"
/>
{{ project.name }}
</label>
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
Aucun projet pour ce client.
</span>
</div>
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
@@ -83,12 +61,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData, UserWrite } from '~/services/dto/user-data' import type { UserData, UserWrite } from '~/services/dto/user-data'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useClientService } from '~/services/clients'
import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -105,32 +77,16 @@ const isOpen = computed({
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}) })
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT'] const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
const isEditing = computed(() => !!props.item) const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
const filteredProjects = computed(() => {
if (form.clientId === null) return []
return allProjects.value.filter(
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
)
})
const form = reactive({ const form = reactive({
username: '', username: '',
password: '', password: '',
roles: [] as string[], roles: [] as string[],
clientId: null as number | null, isEmployee: false,
allowedProjectIds: [] as number[],
}) })
const touched = reactive({ const touched = reactive({
@@ -138,45 +94,21 @@ const touched = reactive({
password: false, password: false,
}) })
function onClientChange(value: number | null) { watch(() => props.modelValue, (open) => {
form.clientId = value
form.allowedProjectIds = []
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
}
}
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
if (props.item) { if (props.item) {
form.username = props.item.username ?? '' form.username = props.item.username ?? ''
form.password = '' form.password = ''
form.roles = [...props.item.roles] form.roles = [...props.item.roles]
form.clientId = props.item.client?.id ?? null form.isEmployee = props.item.isEmployee ?? false
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
} else { } else {
form.username = '' form.username = ''
form.password = '' form.password = ''
form.roles = ['ROLE_USER'] form.roles = ['ROLE_USER']
form.clientId = null form.isEmployee = false
form.allowedProjectIds = []
} }
touched.username = false touched.username = false
touched.password = false touched.password = false
const [loadedClients, loadedProjects] = await Promise.all([
useClientService().getAll(),
useProjectService().getAll({ archived: false }),
])
clients.value = loadedClients
allProjects.value = loadedProjects
} }
}) })
@@ -193,10 +125,7 @@ async function handleSubmit() {
const payload: UserWrite = { const payload: UserWrite = {
username: form.username.trim(), username: form.username.trim(),
roles: form.roles, roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null, isEmployee: form.isEmployee,
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
} }
if (form.password) { if (form.password) {
payload.plainPassword = form.password payload.plainPassword = form.password

View File

@@ -0,0 +1,94 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
const STATUS_VARIANTS: Record<AbsenceStatus, BadgeVariant> = {
pending: 'warning',
approved: 'success',
rejected: 'danger',
cancelled: 'neutral',
}
const STATUS_ICONS: Record<AbsenceStatus, string> = {
pending: 'mdi:clock-outline',
approved: 'mdi:check-circle-outline',
rejected: 'mdi:close-circle-outline',
cancelled: 'mdi:cancel',
}
// Colours used for the calendar bars, keyed by absence type.
const TYPE_COLORS: Record<AbsenceType, string> = {
cp: '#4A90D9',
mariage_pacs: '#E91E63',
naissance: '#26A69A',
conge_parental: '#9C27B0',
deces: '#607D8B',
maladie: '#C62828',
}
export function useAbsenceHelpers() {
const { t } = useI18n()
function statusLabel(status: AbsenceStatus): string {
return t(`absences.status.${status}`)
}
function statusVariant(status: AbsenceStatus): BadgeVariant {
return STATUS_VARIANTS[status] ?? 'neutral'
}
function statusIcon(status: AbsenceStatus): string {
return STATUS_ICONS[status] ?? 'mdi:help-circle-outline'
}
function typeLabel(type: AbsenceType): string {
return t(`absences.types.${type}`)
}
function typeColor(type: AbsenceType): string {
return TYPE_COLORS[type] ?? '#9CA3AF'
}
function halfDayLabel(half: HalfDay): string {
return t(`absences.halfDay.${half}`)
}
function formatDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${day}/${month}/${d.getFullYear()}`
}
/** Human-readable period with half-day annotations. */
function formatRange(req: Pick<AbsenceRequest, 'startDate' | 'endDate' | 'startHalfDay' | 'endHalfDay'>): string {
const start = formatDate(req.startDate)
const end = formatDate(req.endDate)
const startSuffix = req.startHalfDay ? ` (${halfDayLabel(req.startHalfDay)})` : ''
const endSuffix = req.endHalfDay ? ` (${halfDayLabel(req.endHalfDay)})` : ''
if (start === end) {
return `${start}${startSuffix}`
}
return `${start}${startSuffix}${end}${endSuffix}`
}
function formatDays(days: number): string {
const rounded = Math.round(days * 2) / 2
const unit = rounded > 1 ? t('absences.daysPlural') : t('absences.daySingular')
return `${rounded} ${unit}`
}
return {
statusLabel,
statusVariant,
statusIcon,
typeLabel,
typeColor,
halfDayLabel,
formatDate,
formatRange,
formatDays,
}
}

View File

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

View File

@@ -0,0 +1,75 @@
/**
* Mapping des chemins de dossiers système IMAP vers les clés i18n.
* Les clés sont normalisées en minuscules pour la comparaison.
* Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.)
*/
const SYSTEM_FOLDER_MAP: Record<string, string> = {
'inbox': 'mail.systemFolder.inbox',
'sent': 'mail.systemFolder.sent',
'inbox.sent': 'mail.systemFolder.sent',
'sent messages': 'mail.systemFolder.sent',
'drafts': 'mail.systemFolder.drafts',
'inbox.drafts': 'mail.systemFolder.drafts',
'archive': 'mail.systemFolder.archive',
'archives': 'mail.systemFolder.archive',
'inbox.archive': 'mail.systemFolder.archive',
'trash': 'mail.systemFolder.trash',
'deleted': 'mail.systemFolder.trash',
'deleted items': 'mail.systemFolder.trash',
'inbox.trash': 'mail.systemFolder.trash',
'junk': 'mail.systemFolder.junk',
'junk e-mail': 'mail.systemFolder.junk',
'spam': 'mail.systemFolder.junk',
'inbox.junk': 'mail.systemFolder.junk',
}
/**
* Icônes Material Symbols associées aux dossiers système.
* Pour les dossiers non reconnus : utiliser une icône générique.
*/
const SYSTEM_FOLDER_ICONS: Record<string, string> = {
'mail.systemFolder.inbox': 'material-symbols:inbox-outline',
'mail.systemFolder.sent': 'material-symbols:send-outline',
'mail.systemFolder.drafts': 'material-symbols:draft-outline',
'mail.systemFolder.archive': 'material-symbols:archive-outline',
'mail.systemFolder.trash': 'material-symbols:delete-outline',
'mail.systemFolder.junk': 'material-symbols:report-outline',
}
const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline'
export function useSystemFolderLabel() {
const { t } = useI18n()
/**
* Retourne le label traduit d'un dossier système, ou son displayName si inconnu.
* @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent")
* @param displayName - Nom affiché par défaut si non reconnu
*/
function getFolderLabel(path: string, displayName: string): string {
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
return key ? t(key) : displayName
}
/**
* Retourne le nom de l'icône Material Symbols pour un dossier.
* @param path - Chemin IMAP du dossier
*/
function getFolderIcon(path: string): string {
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON
}
/**
* Indique si un dossier est un dossier système reconnu.
*/
function isSystemFolder(path: string): boolean {
return path.toLowerCase() in SYSTEM_FOLDER_MAP
}
return {
getFolderLabel,
getFolderIcon,
isSystemFolder,
}
}

View File

@@ -0,0 +1,24 @@
# Bienvenue dans Lesstime
Lesstime est un outil de **gestion de projets** qui combine plusieurs grandes capacités :
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
-**Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
## Comprendre les rôles
| Rôle | Accès |
|---|---|
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
## Vues principales
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
- **Projets** : un kanban par projet, statuts du workflow associé
- **Time tracking** : timer, time entries, vue mois
- **Admin** : gestion globale (visible uniquement par les admins)
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.

View File

@@ -0,0 +1,58 @@
# Projets & Workflows
## Qu'est-ce qu'un projet ?
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
- Un **client** optionnel (ou interne si null)
- Une **couleur** d'identification
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
## Qu'est-ce qu'un workflow ?
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
### Exemple
| Workflow | Statuts |
|---|---|
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
| **Support** | Nouveau → Diagnostic → Résolu |
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
## Les 5 catégories canoniques
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
| Catégorie | Description |
|---|---|
| `todo` | À faire — pas encore commencé |
| `in_progress` | En cours — quelqu'un bosse dessus |
| `blocked` | Bloqué — attente d'une dépendance |
| `review` | En validation — relecture, PR, QA |
| `done` | Terminé — close |
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
## Changer le workflow d'un projet
1. Ouvrir le projet → **Modifier le projet** (drawer)
2. Section **Workflow** → cliquer sur **Changer de workflow**
3. Sélectionner le workflow cible
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
### Règles du mapping
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
## Supprimer un workflow
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).

View File

@@ -0,0 +1,60 @@
# Mes tâches & Dashboard
## Vue *Mes tâches*
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
### Deux modes d'affichage
#### 1. Kanban (par défaut)
Regroupé par les **5 catégories canoniques** :
```
À faire → En cours → Bloqué → En validation → Terminé
```
Chaque card affiche :
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
#### 2. Liste
Vue tableau triable, avec **bulk actions** :
- Cocher plusieurs tâches → barre d'actions en haut
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
- Supprimer en lot
### Filtres disponibles
| Filtre | Notes |
|---|---|
| **Projet** | Restreint à un projet précis |
| **Groupe** | Disponible uniquement si un projet est sélectionné |
| **Tag** | Tags globaux |
| **Priorité / Effort** | |
| **Assigné** | Par défaut : toi-même |
### Tri (vue liste uniquement)
- Par **deadline** (les plus proches en premier)
- Par **scheduled start** (planification calendrier)
## Vue *Backlog*
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
## Dashboard
Le **dashboard** (page d'accueil après login) affiche :
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
-**Timer actif** s'il y en a un
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).

View File

@@ -0,0 +1,59 @@
# Time tracking
## Le timer
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
### Démarrer un timer
Trois façons :
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
### Arrêter
- Clique sur ⏹ sur la card de la tâche en cours
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
## Time entries
Chaque entrée a :
| Champ | Description |
|---|---|
| **Titre** | Description courte (ex: "Réunion daily") |
| **Projet** | Obligatoire |
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
| **Début / Fin** | Datetimes — la durée est calculée |
| **User** | Qui a fait le travail |
### Vue *Time tracking*
Disponible en deux modes :
- **Vue semaine** : ligne par ligne, par jour
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
### Filtres
- **Projet** (server-side)
- **Tag** (server-side)
- **User** (admin uniquement)
- **Période** (date début / date fin)
## Édition
- Clique sur une time entry → drawer d'édition
- Tu peux modifier projet, tâche, tags, dates a posteriori
- La suppression est libre — pense à exporter avant si nécessaire
## Tags
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.

View File

@@ -0,0 +1,58 @@
# Détail d'une tâche
## Champs principaux
| Champ | Notes |
|---|---|
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
| **Titre** | Obligatoire |
| **Description** | Markdown supporté (preview disponible) |
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
| **Assigné** | Un seul user responsable |
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
| **Tags** | Globaux, plusieurs par tâche |
| **Deadline** | Date — un badge coloré apparaît sur la card |
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
## Récurrence
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
- **Type** : quotidien, hebdomadaire, mensuel
- **Intervalle** : tous les N jours/semaines/mois
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
## Sync calendrier
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
Icônes correspondantes :
- 🟢 `mdi:calendar-check` → sync OK
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
## Documents
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
- Drag & drop dans la tâche pour uploader
- Validation du **MIME type côté serveur** (pas seulement l'extension)
- Téléchargement via lien dédié
## Liaison Gitea (si configuré)
Si le projet a un repo Gitea lié, tu peux :
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
- **Voir les PRs** liées (état CI inclus)
## Commentaires & notifications
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
- Les @mentions notifient l'utilisateur cité
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues

View File

@@ -0,0 +1,76 @@
# Absences
Le module **Absences** gère les congés des salariés : demande, validation, et suivi du **solde de congés payés (CP)**. Les congés pour événements familiaux (mariage/PACS, naissance, décès) sont des **droits par événement** : ils sont enregistrés et validés mais **ne se déduisent pas d'un solde**. Le congé parental et l'arrêt maladie sont des suspensions, sans impact sur les soldes.
> Convention de référence pour les valeurs par défaut : **Syntec (IDCC 1486)** — à confirmer selon le code APE de l'entreprise (une CCN ne se déduit pas de la seule activité).
Il y a deux espaces :
- **Mes absences** (`/absences`) — accessible à tout salarié : poser une demande et consulter ses soldes.
- **Absences équipe** (`/team-absences`) — réservé aux **administrateurs** : valider les demandes, voir le calendrier d'équipe, ajuster les soldes et gérer la fiche RH des salariés.
> 🛡️ La gestion des salariés (onglet *Employés*) et la validation des demandes sont aujourd'hui réservées au rôle **ROLE_ADMIN**.
## Poser une demande (tout salarié)
Depuis **Mes absences** → bouton *Nouvelle demande* :
1. **Type** : Congés payés, Mariage/PACS, Naissance, Congé parental, Décès, ou Maladie.
2. **Dates** : début et fin. Une **demi-journée** (matin / après-midi) peut être posée sur le premier ou le dernier jour (décompte 0,5, uniquement si ce jour-borne est un jour décompté).
3. **Motif** et **justificatif** (selon le type). Le **motif est obligatoire pour le décès** : il sert à préciser le lien de parenté, qui détermine le nombre de jours légal.
La demande passe au statut **En attente**, puis un administrateur la valide ou la refuse. Pour les CP uniquement, les jours sont immédiatement **réservés** dans le solde « en attente » pour éviter de poser deux fois les mêmes congés.
> **Congés pour événements familiaux — minimums légaux (rappel).** Mariage/PACS : 4 jours. Naissance : 3 jours (hors congé paternité). Décès : selon le lien — **enfant : au moins 5 jours + 8 jours de congé de deuil**, conjoint/partenaire/parent/frère/sœur : 3 jours. L'administrateur accorde le nombre de jours légal en validant les dates. La convention Syntec peut prévoir des durées supérieures.
## Lire ses soldes
Chaque solde de CP se lit sur une **période de référence** (par défaut **1er juin → 31 mai**) et se décompose en :
| Bucket | Signification |
|---|---|
| **Acquis (N-1)** | Jours déjà acquis sur la période précédente, **disponibles** maintenant |
| **En cours d'acquisition (N)** | Jours qui s'accumulent ce mois-ci, disponibles à la prochaine période |
| **En attente** | Jours réservés par des demandes non encore validées |
| **Pris** | Jours de demandes validées |
Le **disponible** = acquis + acquis-en-cours en attente pris.
## Comment les congés payés sont comptés
L'acquisition est **mensuelle**, créditée par une tâche planifiée (commande `app:accrue-leave`, lancée par un cron en début de mois) :
```
acquisition mensuelle = (jours de CP annuels ÷ 12) × quotité de travail
```
Pour un temps plein à **25 jours/an**, cela fait **≈ 2,08 jour/mois** crédités dans « en cours d'acquisition (N) ». À chaque changement de période de référence, le « en cours (N) » bascule automatiquement en « acquis (N-1) ».
Le décompte d'une absence ne compte que les **jours ouvrés** (lundi→vendredi) ; les week-ends sont ignorés. Les arrêts **maladie** ne sont pas déduits du solde de CP.
## Ajouter un salarié
> Aujourd'hui réalisé par un **administrateur** depuis **Absences équipe → onglet *Employés* → *Ajouter / Modifier***.
Cocher *Employé* sur la fiche d'un utilisateur l'intègre à la gestion des absences. La fiche RH demande :
| Champ | Rôle | Valeur typique |
|---|---|---|
| **Date d'embauche** | Début du contrat | date réelle |
| **Type de contrat** | CDI, CDD, Stage, Alternance, Autre | — |
| **Quotité de travail** | Temps plein = `1.0`, mi-temps = `0.5` | `1.0` |
| **CP annuels** | Jours de CP acquis par an | `25` |
| **Début de période de référence** | Format `MM-JJ` | `06-01` |
| **Solde initial de CP** | Jours déjà acquis et disponibles à l'activation | voir ci-dessous |
### Nouveau salarié qui arrive
Renseigner date d'embauche, contrat, quotité, `25` CP annuels et `06-01` de période. Laisser le **solde initial à `0`** : le salarié commence à acquérir ses CP au prochain passage mensuel.
### Salarié déjà présent avant l'activation du module
C'est le **solde initial de CP** qui sert à reprendre l'existant. Y saisir le **nombre de jours de CP déjà acquis et disponibles** par le salarié au moment où on active le module.
Au premier calcul mensuel, cette valeur amorce le bucket **« acquis (N-1) »** (donc immédiatement disponible), puis l'acquisition normale (~2,08 j/mois) reprend par-dessus. Les salariés déjà présents sont ainsi « comptés » sans repartir de zéro — il suffit de connaître leur solde de départ (depuis l'ancien suivi : tableur, fiches de paie, etc.).
> 💡 En cas d'erreur de reprise, un administrateur peut **ajuster un solde** à la main depuis l'onglet *Soldes* (régularisation).

View File

@@ -0,0 +1,73 @@
# Administration
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
## Onglet *Clients*
- Liste des clients (entreprise / organisation)
- Champs : nom, email, téléphone, adresse
- Lier un client à des projets
## Onglet *Workflows*
**Nouveau** — remplace l'ancien onglet *Statuts*.
- Lister les workflows existants
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
## Onglet *Efforts*
- Tailles d'effort (S, M, L, XL, XXL)
- Globales (partagées entre tous les projets)
## Onglet *Priorités*
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
## Onglet *Tags*
- Tags globaux (tâches **et** time entries)
- Couleur personnalisable
- Pas de hiérarchie (flat list)
## Onglet *Utilisateurs*
- Créer / éditer / désactiver
- Rôles : `ROLE_ADMIN`, `ROLE_USER`
- Reset password depuis l'admin
## Onglet *Gitea*
- URL serveur + token API
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
- Active les fonctionnalités branches / PRs sur les tâches
## Onglet *BookStack*
- URL + token API
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
## Onglet *Zimbra*
- URL serveur + credentials (chiffrés via libsodium)
- Configure le calendrier CalDav par défaut
- Test de connexion intégré
- Active la **sync calendrier** sur les tâches planifiées
## Onglet *Mail*
Configure la **boîte mail partagée** (OVH) lue dans la section *Messagerie*.
- **Réception (IMAP)** : hôte, port (993 par défaut), chiffrement (SSL / TLS / aucun)
- **Envoi (SMTP)** : hôte, port (465 par défaut), chiffrement
- **Identifiants** : nom d'utilisateur + mot de passe (chiffré côté serveur, jamais réaffiché — un indicateur signale qu'un mot de passe est déjà enregistré), et chemin du dossier *Envoyés*
- **Toggle `enabled`** : active la messagerie
- **Test de connexion** intégré (vérifie l'accès IMAP et compte les dossiers)

View File

@@ -0,0 +1,92 @@
# Intégrations
Lesstime s'intègre avec **4 outils externes** pour fluidifier le workflow dev.
## 🌳 Gitea
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
### Configuration
1. **Admin → Gitea** : URL serveur + token API
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
### Utilisation
Sur une tâche, le panneau Gitea propose :
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
## 📚 BookStack
Lien tâche → documentation.
### Configuration
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
### Utilisation
- Depuis une tâche : bouton **Lier à une page BookStack**
- Sélectionner la page dans le shelf du projet
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
## 📅 Zimbra (CalDav)
Sync calendrier pour les tâches planifiées.
### Configuration
1. **Admin → Zimbra** :
- URL serveur (ex: `https://mail.ovh.com`)
- Username (ex: `lesstime@ovh.fr`)
- Password (chiffré côté serveur)
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
- **Test de connexion** intégré
2. Active la config (toggle `enabled`)
### Utilisation
Sur une tâche avec **scheduled start + end** :
1. Cocher **Sync calendrier**
2. Au save, Lesstime crée/met à jour l'événement CalDav
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
### Limites
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
## 📧 Messagerie (Mail OVH)
Boîte mail partagée OVH (IMAP) lue directement dans Lesstime.
### Configuration
1. **Admin → Mail** :
- Réception **IMAP** (hôte, port, chiffrement) et envoi **SMTP** (hôte, port, chiffrement)
- Identifiants (mot de passe chiffré côté serveur) + dossier *Envoyés*
- **Test de connexion** intégré
2. Active la config (toggle `enabled`)
### Utilisation
- La section **Messagerie** (barre latérale) affiche dossiers, messages et lecteur
- **Synchronisation IMAP à la demande** via le bouton *Rafraîchir* (traitée en asynchrone par Messenger)
- Depuis un mail : **créer une tâche** pré-remplie ou **lier à une tâche** existante
- Badge de non-lus dans la barre latérale, rafraîchi automatiquement (toutes les 30 s)
> 📖 Le guide complet de la messagerie est dans la section *Messagerie*.
### Limites
- **Lecture seule** : pas de rédaction / réponse / suppression de mail depuis l'interface
- Réservée aux rôles **admin** et **user** (pas les clients)

View File

@@ -0,0 +1,97 @@
# Token MCP & API
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
## Générer ton token
1. Va sur **Profil** (avatar → Profil)
2. Section **Token MCP****Générer un token**
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
## Configurer Claude Code
Dans `.mcp.json` (à la racine de ton projet) :
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "https://ton-instance-lesstime/_mcp",
"headers": {
"Authorization": "Bearer TON_TOKEN_ICI"
}
}
}
}
```
Pour une instance locale :
```json
{
"mcpServers": {
"lesstime-local": {
"command": "docker",
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
}
}
}
```
## Tools disponibles (27 au total)
### Projets
- `list-projects`, `get-project`, `create-project`, `update-project`
### Tâches
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
- `get-task`, `create-task`, `update-task`, `delete-task`
### Métadonnées
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
- `list-priorities`, `list-efforts`, `list-tags`
### Workflows ⭐ Nouveau
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
### Time tracking
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
### Récurrence
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
### Groupes / Users / Clients
- `list-groups`, `create-group`, `update-group`
- `list-users`, `list-clients`
## Règles importantes
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
## Exemples de prompts
```
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
```
```
"Liste mes tâches en cours dans le projet CRM"
```
```
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
```
L'agent appelle les bons tools tout seul si la description est claire.

View File

@@ -0,0 +1,40 @@
# Messagerie
Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directement dans l'application. Tu lis les mails de l'équipe et tu les transformes en tâches sans quitter Lesstime.
> 📥 La messagerie est accessible depuis l'entrée **Messagerie** de la barre latérale (icône enveloppe). Un **badge** y affiche le nombre de mails non lus, toutes boîtes confondues.
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**.
## L'interface
L'écran est organisé en **3 colonnes** :
1. **Dossiers** — l'arborescence de la boîte (INBOX, Envoyés, sous-dossiers…), avec le compteur de non-lus par dossier. INBOX est sélectionné par défaut.
2. **Messages** — la liste du dossier sélectionné (expéditeur, objet, date). Les mails non lus sont mis en avant. Un bouton **Charger plus** récupère les messages suivants (pagination).
3. **Lecteur** — le mail sélectionné : en-tête (expéditeur, destinataires, date), corps du message et **pièces jointes**.
## Lire un message
- Clique sur un message dans la liste : son détail s'affiche et il est **automatiquement marqué comme lu**.
- Tu peux le repasser **non lu** ou l'**étoiler** (flag) pour le retrouver plus tard.
- Les **pièces jointes** sont listées dans le lecteur : clique pour les télécharger, les images peuvent être prévisualisées.
## Synchronisation
- Le bouton **Rafraîchir** (en haut de l'écran) déclenche une **synchronisation IMAP à la demande** : Lesstime va chercher les nouveaux mails sur le serveur. Le traitement est asynchrone, la liste se met à jour quelques secondes après.
- En arrière-plan, le **compteur de non-lus** de la barre latérale se rafraîchit automatiquement (toutes les 30 s).
## Transformer un mail en action
Depuis le lecteur, deux boutons relient un mail au suivi de projet :
- **Créer une tâche** — ouvre une tâche pré-remplie à partir du mail (objet, contenu). Tu choisis le projet et les métadonnées, le mail reste lié à la tâche.
- **Lier à une tâche** — rattache le mail à une tâche **existante**.
> 💡 C'est le pont entre la boîte mail de l'équipe et le kanban : une demande reçue par mail devient une tâche traçable en deux clics.
## Limites
- **Lecture seule** : l'interface ne permet pas (encore) de **rédiger, répondre ou transférer** un mail, ni de supprimer un message.
- La configuration du serveur (IMAP/SMTP, identifiants) se fait dans **Admin → Mail** — voir la section *Administration*.

View File

@@ -56,6 +56,37 @@
"moveTo": "Déplacer vers", "moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)" "backlog": "Backlog (sans statut)"
}, },
"workflows": {
"title": "Workflows",
"addWorkflow": "Ajouter un workflow",
"editWorkflow": "Modifier le workflow",
"name": "Nom",
"isDefault": "Workflow par défaut",
"statuses": "Statuts",
"addStatus": "Ajouter un statut",
"category": "Catégorie",
"created": "Workflow créé",
"updated": "Workflow mis à jour",
"deleted": "Workflow supprimé",
"switched": "Workflow du projet changé",
"switchTitle": "Changer de workflow",
"switchTargetLabel": "Nouveau workflow",
"switchMappingTitle": "Mapping des statuts",
"switchSourceCol": "Statut actuel",
"switchTargetCol": "Statut cible",
"switchTaskCountCol": "Tâches",
"switchToBacklog": "Mapper vers le backlog",
"switchConfirm": "Confirmer la migration",
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
"categories": {
"todo": "À faire",
"in_progress": "En cours",
"blocked": "Bloqué",
"review": "En validation",
"done": "Terminé"
}
},
"taskEfforts": { "taskEfforts": {
"created": "Effort créé avec succès.", "created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.", "updated": "Effort mis à jour avec succès.",
@@ -205,7 +236,8 @@
"sortBy": "Trier par", "sortBy": "Trier par",
"sortDefault": "Par défaut", "sortDefault": "Par défaut",
"sortDeadline": "Échéance", "sortDeadline": "Échéance",
"sortScheduledStart": "Date planifiée" "sortScheduledStart": "Date planifiée",
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -319,63 +351,6 @@
"error": "Erreur de connexion à Gitea.", "error": "Erreur de connexion à Gitea.",
"notConfigured": "Gitea non configuré pour ce projet." "notConfigured": "Gitea non configuré pour ce projet."
}, },
"portal": {
"title": "Portail client",
"projects": "Vos projets",
"noProjects": "Aucun projet disponible.",
"openTickets": "tickets ouverts",
"newTicket": "Nouveau ticket",
"ticketDetail": "Détail du ticket",
"backToProject": "Retour au projet",
"submitTicket": "Soumettre le ticket",
"ticketCreated": "Ticket soumis avec succès."
},
"clientTicket": {
"title": "Tickets",
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"updated": "Ticket mis à jour avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
"improvement": "Amélioration",
"other": "Autre"
},
"status": {
"new": "Nouveau",
"in_progress": "En cours",
"done": "Terminé",
"rejected": "Rejeté"
},
"fields": {
"title": "Titre",
"description": "Description",
"url": "URL de la page",
"urlPlaceholder": "https://example.com/page-concernee",
"type": "Type",
"project": "Projet"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
"rejectComment": "Commentaire de rejet",
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
"linkedTicket": "Lié au ticket client CT-{number}",
"description": "Description",
"url": "URL (page concernée)",
"statusComment": "Commentaire de statut",
"statusChanged": "Statut mis à jour",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"linkedTooltip": "Lié au ticket client {number}",
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
"noTickets": "Aucun ticket.",
"allStatuses": "Tous les statuts",
"allProjects": "Tous les projets",
"submittedBy": "Soumis par",
"createdAt": "Créé le",
"adminTab": "Tickets client",
"selectType": "Type de ticket",
"changeStatus": "Changer le statut"
},
"notification": { "notification": {
"title": "Notifications", "title": "Notifications",
"markAllRead": "Tout marquer comme lu", "markAllRead": "Tout marquer comme lu",
@@ -393,7 +368,21 @@
"title": "Mon profil", "title": "Mon profil",
"changeAvatar": "Changer l'avatar", "changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar", "removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar" "cropAvatar": "Recadrer l'avatar",
"apiToken": {
"title": "Token API MCP",
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
"label": "Token",
"empty": "Aucun token généré pour le moment.",
"generate": "Générer un token",
"regenerate": "Régénérer",
"copy": "Copier",
"copied": "Token copié dans le presse-papiers.",
"copyFailed": "Impossible de copier le token.",
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
"confirmTitle": "Régénérer le token MCP ?",
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
}
}, },
"bookstack": { "bookstack": {
"settings": { "settings": {
@@ -447,5 +436,308 @@
"weekly": "Hebdomadaire", "weekly": "Hebdomadaire",
"monthly": "Mensuel", "monthly": "Mensuel",
"yearly": "Annuel" "yearly": "Annuel"
},
"mail": {
"title": "Messagerie",
"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)"
},
"folders": "Dossiers",
"messages": "Messages",
"viewer": "Lecture",
"empty": {
"folder": "Aucun dossier disponible.",
"list": "Aucun message dans ce dossier.",
"viewer": "Sélectionnez un message pour le lire."
},
"preview": {
"open": "Prévisualiser",
"close": "Fermer l'aperçu",
"loading": "Chargement de l'aperçu…",
"unavailable": "Aperçu indisponible pour ce type de fichier."
},
"folderTree": {
"expand": "Déplier le dossier",
"collapse": "Replier le dossier"
},
"systemFolder": {
"inbox": "Boîte de réception",
"sent": "Éléments envoyés",
"drafts": "Brouillons",
"archive": "Archives",
"trash": "Corbeille",
"junk": "Indésirables"
},
"actions": {
"refresh": "Actualiser",
"createTask": "Créer une tâche",
"linkTask": "Lier à une tâche",
"markRead": "Marquer comme lu",
"markUnread": "Marquer comme non lu",
"flag": "Marquer important",
"unflag": "Retirer l'importance",
"download": "Télécharger",
"showImages": "Afficher les images"
},
"errors": {
"syncFailed": "Erreur lors de la synchronisation.",
"fetchFailed": "Impossible de charger les messages.",
"notAuthorized": "Vous n'avez pas accès à la messagerie."
},
"configuration": {
"saved": "Configuration mail enregistrée."
},
"task": {
"created": "Tâche créée depuis le mail.",
"linked": "Mail lié à la tâche.",
"unlinked": "Lien supprimé."
},
"createTaskModal": {
"title": "Créer une tâche depuis ce mail",
"submit": "Créer la tâche",
"projectLabel": "Projet *",
"projectPlaceholder": "Sélectionner un projet",
"groupLabel": "Groupe (optionnel)",
"groupPlaceholder": "Aucun groupe",
"statusLabel": "Statut",
"assigneeLabel": "Assigné à",
"assigneePlaceholder": "Aucun",
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
"descriptionHint": "La description sera remplie depuis le corps du mail."
},
"linkTaskModal": {
"title": "Lier à une tâche existante",
"submit": "Lier la tâche",
"searchPlaceholder": "Rechercher une tâche par titre…",
"projectFilter": "Filtrer par projet",
"projectAll": "Tous les projets",
"empty": "Aucune tâche correspondante.",
"loading": "Recherche en cours…"
},
"taskTab": {
"title": "Mails",
"empty": "Aucun mail lié à cette tâche.",
"openInMailer": "Ouvrir dans la messagerie",
"unlinkConfirm": "Délier ce mail ?"
},
"sync": {
"dispatched": "Synchronisation lancée en arrière-plan."
},
"attachments": "Pièces jointes",
"noAttachments": "Aucune pièce jointe.",
"from": "De",
"to": "À",
"cc": "Cc",
"date": "Date",
"subject": "Sujet",
"noSubject": "(Sans objet)",
"loadMore": "Charger plus",
"loading": "Chargement…",
"hasAttachments": "Pièces jointes",
"unread": "non lu | non lus",
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
},
"absences": {
"title": "Mes absences",
"teamTitle": "Absences de l'équipe",
"newRequest": "Nouvelle demande",
"daySingular": "jour",
"daysPlural": "jours",
"noBalance": "Aucun solde à afficher.",
"noRequests": "Aucune demande.",
"remaining": "restants",
"acquired": "acquis",
"acquiredN1": "Acquis (N-1)",
"acquiringN": "En cours d'acquisition (N)",
"acquiringHint": "posables par anticipation",
"taken": "pris",
"pending": "en attente",
"available": "disponible",
"types": {
"cp": "Congés payés",
"mariage_pacs": "Mariage / PACS",
"naissance": "Naissance",
"conge_parental": "Congé parental",
"deces": "Décès proche",
"maladie": "Arrêt maladie"
},
"status": {
"pending": "En attente",
"approved": "Approuvée",
"rejected": "Refusée",
"cancelled": "Annulée"
},
"halfDay": {
"matin": "Matin",
"apres_midi": "Après-midi"
},
"table": {
"type": "Type",
"period": "Période",
"days": "Jours",
"status": "Statut",
"employee": "Salarié",
"year": "Année",
"requestedAt": "Demandé le",
"actions": "Actions"
},
"filters": {
"allStatuses": "Tous les statuts",
"allTypes": "Tous les types",
"allYears": "Toutes les années",
"allEmployees": "Tous les salariés"
},
"form": {
"type": "Type d'absence",
"startDate": "Date de début",
"endDate": "Date de fin",
"startHalfDay": "Demi-journée (début)",
"endHalfDay": "Demi-journée (fin)",
"halfDayCheckbox": "Demi-journée",
"reason": "Motif",
"reasonPlaceholder": "Précisez le motif si nécessaire…",
"justification": "Justificatif",
"computed": "{days} décompté(s)",
"balanceAfter": "Solde restant après cette demande : {value}",
"negativeWarning": "Cette demande dépasse votre solde disponible.",
"noticeWarning": "Le délai de prévenance ({days} jours) n'est pas respecté.",
"submit": "Soumettre la demande",
"justificationRequired": "Un justificatif est requis pour ce type d'absence.",
"fullDay": "Journée entière",
"balanceAt": "Solde au {date}",
"balanceAfterValidation": "Solde après validation",
"duration": "Durée de la demande",
"commentPlaceholder": "Écrire un commentaire…",
"serverError": "La demande n'a pas pu être enregistrée.",
"errors": {
"typeRequired": "Veuillez choisir un type d'absence.",
"startRequired": "Veuillez indiquer une date de début.",
"endRequired": "Veuillez indiquer une date de fin.",
"endBeforeStart": "La date de fin doit être après la date de début.",
"zeroDays": "La période sélectionnée ne décompte aucun jour.",
"justificationRequired": "Un justificatif est obligatoire pour ce type d'absence."
}
},
"detail": {
"title": "Détail de la demande",
"timeline": "Historique",
"created": "Demande créée",
"reviewed": "Traitée par {name}",
"rejectionReason": "Motif du refus",
"downloadJustification": "Télécharger le justificatif",
"cancel": "Annuler ma demande",
"cancelConfirm": "Annuler cette demande ?"
},
"review": {
"approve": "Valider",
"reject": "Refuser",
"rejectTitle": "Refuser la demande",
"rejectReasonLabel": "Motif du refus",
"rejectReasonPlaceholder": "Expliquez la raison du refus…",
"confirm": "Confirmer"
},
"admin": {
"tabs": {
"requests": "Demandes",
"calendar": "Calendrier",
"balances": "Soldes",
"employees": "Employés"
},
"kpis": {
"pending": "En attente",
"todayAbsent": "Absents aujourd'hui",
"weekAbsent": "Absents cette semaine"
},
"balancesTable": {
"employee": "Salarié",
"type": "Type",
"period": "Période",
"acquired": "Acquis (N-1)",
"acquiring": "En cours (N)",
"taken": "Pris",
"pending": "En attente",
"available": "Disponible",
"adjust": "Ajuster"
},
"adjust": {
"title": "Ajuster le solde",
"acquired": "Acquis (N-1)",
"acquiring": "En cours d'acquisition (N)",
"taken": "Pris",
"save": "Enregistrer"
},
"employees": {
"columns": {
"name": "Nom",
"contract": "Contrat",
"cpTaken": "CP pris",
"cpRemaining": "CP restants"
},
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
"noContract": "—",
"drawer": {
"title": "Informations employé",
"save": "Enregistrer"
},
"fields": {
"hireDate": "Date d'embauche",
"endDate": "Date de sortie",
"contractType": "Type de contrat",
"workTimeRatio": "Temps de travail (ex : 1.0)",
"annualLeaveDays": "CP annuels (jours)",
"referencePeriodStart": "Début période réf. (MM-DD)",
"initialLeaveBalance": "Solde CP initial"
},
"contract": {
"cdi": "CDI",
"cdd": "CDD",
"stage": "Stage",
"alternance": "Alternance",
"autre": "Autre"
}
}
},
"policies": {
"title": "Politiques d'absence",
"subtitle": "Réglez les défauts par type d'absence — convention de référence : Syntec (IDCC 1486), à confirmer selon le code APE.",
"type": "Type",
"daysPerYear": "Jours / an",
"daysPerEvent": "Jours / événement",
"justificationRequired": "Justificatif requis",
"noticeDays": "Délai prévenance (j)",
"countWorkingDaysOnly": "Jours ouvrés",
"active": "Actif",
"save": "Enregistrer"
},
"toast": {
"created": "Demande d'absence créée.",
"approved": "Demande validée.",
"rejected": "Demande refusée.",
"cancelled": "Demande annulée.",
"justificationUploaded": "Justificatif ajouté.",
"balanceAdjusted": "Solde ajusté.",
"policyUpdated": "Politique mise à jour."
}
} }
} }

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