Compare commits
12 Commits
v0.4.5
...
feat/absen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65df36dd1a | ||
|
|
f9773b3a5e | ||
|
|
e9aaccc62c | ||
|
|
4858f73d07 | ||
|
|
11fdf8d1bf | ||
|
|
2b148fa65a | ||
|
|
2a0b202d32 | ||
|
|
de98924fd3 | ||
|
|
325a7b07f9 | ||
|
|
bcbc04325e | ||
|
|
8f2a688740 | ||
|
|
6491943930 |
23
CLAUDE.md
23
CLAUDE.md
@@ -14,10 +14,10 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, 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/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/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
@@ -31,12 +31,12 @@ migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||
frontend/layouts/ # Layouts (default, portal)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (default)
|
||||
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, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, 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/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -85,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`)
|
||||
- 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
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
|
||||
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml`
|
||||
- `User::getRoles()` ajoute toujours `ROLE_USER`
|
||||
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
||||
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||
- Endpoints ouverts à tout utilisateur authentifié : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -102,8 +102,6 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||
- 4 espaces d'indentation
|
||||
- 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
|
||||
|
||||
@@ -138,7 +136,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
||||
|
||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||
|
||||
@@ -6,8 +6,14 @@ mcp:
|
||||
This server provides access to the Lesstime project management system.
|
||||
You can list/create/update/delete projects, tasks, and time entries.
|
||||
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
|
||||
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
|
||||
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).
|
||||
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.
|
||||
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
|
||||
available metadata before creating or updating tasks.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
security:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||
ROLE_ADMIN: [ROLE_USER]
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
@@ -64,7 +64,7 @@ security:
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
# Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker)
|
||||
# Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker)
|
||||
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
parameters:
|
||||
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||
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:
|
||||
- { resource: version.yaml }
|
||||
@@ -44,3 +48,11 @@ services:
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.5'
|
||||
app.version: '0.4.7'
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
> 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.
|
||||
@@ -45,6 +46,8 @@
|
||||
- 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
|
||||
@@ -107,7 +110,7 @@ Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `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)
|
||||
|
||||
571
docs/superpowers/plans/2026-05-22-employee-management-reorg.md
Normal file
571
docs/superpowers/plans/2026-05-22-employee-management-reorg.md
Normal 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`). ✅
|
||||
2519
docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md
Normal file
2519
docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal file
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal 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>
|
||||
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal file
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal 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>
|
||||
143
frontend/components/absence/AbsenceCalendar.vue
Normal file
143
frontend/components/absence/AbsenceCalendar.vue
Normal 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>
|
||||
73
frontend/components/absence/AbsenceDateField.vue
Normal file
73
frontend/components/absence/AbsenceDateField.vue
Normal 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>
|
||||
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal file
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal 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>
|
||||
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal file
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal 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>
|
||||
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal file
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal 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>
|
||||
137
frontend/components/absence/EmployeeDrawer.vue
Normal file
137
frontend/components/absence/EmployeeDrawer.vue
Normal 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>
|
||||
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal file
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||
<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"
|
||||
|
||||
@@ -1,353 +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">
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</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>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
group-class="mt-1"
|
||||
/>
|
||||
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -1,333 +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">
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<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>
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
|
||||
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
121
frontend/components/mail/MailAttachmentPreview.vue
Normal 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>
|
||||
@@ -119,20 +119,20 @@ async function handleSubmit(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||
<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')" min-width="w-full" :disabled="loadingGroups" />
|
||||
<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')" min-width="w-full" />
|
||||
<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')" min-width="w-full" />
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ async function handleSubmit(): Promise<void> {
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
@@ -24,22 +24,101 @@ const sanitizedBody = computed((): string => {
|
||||
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)
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
triggerBlobDownload(data, filename)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
@@ -169,15 +248,31 @@ function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
:key="att.downloadId"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||
:title="att.filename"
|
||||
@click="handleDownload(att.downloadId, att.filename)"
|
||||
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||
@click="handleAttachmentClick(att)"
|
||||
>
|
||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<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>
|
||||
|
||||
@@ -106,19 +106,7 @@ function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="codeProxy"
|
||||
@@ -27,7 +30,7 @@
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
@@ -39,7 +42,7 @@
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +52,7 @@
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:options="targetOptions"
|
||||
:label="$t('workflows.switchTargetLabel')"
|
||||
empty-option-label="—"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
|
||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
@@ -39,7 +39,7 @@
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
@@ -50,7 +50,7 @@
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
@@ -61,7 +61,7 @@
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
@@ -73,7 +73,7 @@
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
|
||||
@@ -20,12 +20,6 @@
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,13 @@ import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
clientTicketId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -110,9 +109,7 @@ async function processFiles(files: File[]) {
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.clientTicketId) {
|
||||
await uploadForTicket(props.clientTicketId, file)
|
||||
} else if (props.taskId) {
|
||||
if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
|
||||
@@ -35,23 +35,6 @@
|
||||
@click="close"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -91,7 +74,7 @@
|
||||
:options="projectOptions"
|
||||
label="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">
|
||||
Le projet est requis
|
||||
@@ -105,43 +88,35 @@
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="clientTicketOptions.length"
|
||||
v-model="form.clientTicketId"
|
||||
:options="clientTicketOptions"
|
||||
label="Ticket client"
|
||||
empty-option-label="Aucun ticket client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -549,10 +524,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
@@ -627,7 +600,6 @@ const form = reactive({
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
scheduledStart: '',
|
||||
scheduledEnd: '',
|
||||
@@ -757,7 +729,6 @@ function populateForm(task: Task | null) {
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||
@@ -804,7 +775,6 @@ function populateForm(task: Task | null) {
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
form.scheduledStart = ''
|
||||
form.scheduledEnd = ''
|
||||
@@ -833,16 +803,6 @@ watch(() => props.modelValue, async (open) => {
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
try {
|
||||
const settings = await getGiteaSettings()
|
||||
@@ -862,48 +822,26 @@ watch(() => props.task, (task) => {
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const clientTickets = ref<ClientTicket[]>([])
|
||||
const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// Reset group and reload client tickets when project changes in create mode
|
||||
watch(() => form.projectId, async (pid) => {
|
||||
// Reset group when project changes in create mode
|
||||
watch(() => form.projectId, () => {
|
||||
if (!showProjectSelect.value) return
|
||||
form.groupId = null
|
||||
form.clientTicketId = null
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
const isMailUser = computed(() => !isClientOnly.value)
|
||||
|
||||
const availableTabs = computed(() => {
|
||||
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||
if (isEditing.value && isMailUser.value) base.push('mails')
|
||||
if (isEditing.value) base.push('mails')
|
||||
return base
|
||||
})
|
||||
|
||||
async function loadLinkedMails(): Promise<void> {
|
||||
if (!props.task || !isMailUser.value) return
|
||||
if (!props.task) return
|
||||
mailsLoading.value = true
|
||||
try {
|
||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||
@@ -928,16 +866,6 @@ function formatMailDate(iso: string | null): string {
|
||||
})
|
||||
}
|
||||
|
||||
function ticketStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
@@ -1057,7 +985,6 @@ async function handleSubmit() {
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
scheduledStart: form.scheduledStart || null,
|
||||
scheduledEnd: form.scheduledEnd || null,
|
||||
deadline: form.deadline || null,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -58,7 +61,7 @@
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
@@ -66,7 +69,7 @@
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
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'">
|
||||
{{ day.dayNum }}
|
||||
@@ -16,6 +17,14 @@
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</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>
|
||||
</div>
|
||||
@@ -40,6 +49,7 @@
|
||||
:key="day.dateStr"
|
||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
@click="onClickGrid($event, day)"
|
||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||
>
|
||||
@@ -141,8 +151,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
|
||||
const props = defineProps<{
|
||||
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 ---
|
||||
const days = computed(() => {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
@@ -231,6 +260,7 @@ const days = computed(() => {
|
||||
dateStr,
|
||||
dayNum: d.getDate(),
|
||||
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')}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<!-- Period presets -->
|
||||
<div>
|
||||
@@ -52,7 +55,7 @@
|
||||
:label="$t('timeEntries.exportUsers')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +66,7 @@
|
||||
:options="clientOptions"
|
||||
:label="$t('timeEntries.exportClient')"
|
||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +78,7 @@
|
||||
:label="$t('timeEntries.exportProjects')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +90,7 @@
|
||||
:label="$t('timeEntries.exportTags')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
32
frontend/components/ui/StatusBadge.vue
Normal file
32
frontend/components/ui/StatusBadge.vue
Normal 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>
|
||||
@@ -1,5 +1,8 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -35,37 +38,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
placeholder="Aucun client"
|
||||
class="w-full"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.clientId !== null" class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowedProjectIds"
|
||||
type="checkbox"
|
||||
:value="project.id"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
{{ project.name }}
|
||||
</label>
|
||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
||||
Aucun projet pour ce client.
|
||||
</span>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
@@ -83,12 +61,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||
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<{
|
||||
modelValue: boolean
|
||||
@@ -105,32 +77,16 @@ const isOpen = computed({
|
||||
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 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({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
isEmployee: false,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -138,45 +94,21 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
function onClientChange(value: number | null) {
|
||||
form.clientId = value
|
||||
form.allowedProjectIds = []
|
||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.roles, (roles) => {
|
||||
if (!roles.includes('ROLE_CLIENT')) {
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.clientId = props.item.client?.id ?? null
|
||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
form.isEmployee = false
|
||||
}
|
||||
touched.username = 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 = {
|
||||
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
|
||||
|
||||
94
frontend/composables/useAbsenceHelpers.ts
Normal file
94
frontend/composables/useAbsenceHelpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
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
|
||||
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||
|
||||
## Comprendre les rôles
|
||||
|
||||
@@ -13,7 +12,6 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités
|
||||
|---|---|
|
||||
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||
|
||||
## Vues principales
|
||||
|
||||
@@ -22,6 +20,5 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités
|
||||
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||
- **Time tracking** : timer, time entries, vue mois
|
||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||
|
||||
> 💡 **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.
|
||||
|
||||
@@ -51,10 +51,6 @@ Si le projet a un repo Gitea lié, tu peux :
|
||||
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||
- **Voir les PRs** liées (état CI inclus)
|
||||
|
||||
## Liaison ticket client
|
||||
|
||||
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||
|
||||
## Commentaires & notifications
|
||||
|
||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||
|
||||
76
frontend/content/help/06-absences.md
Normal file
76
frontend/content/help/06-absences.md
Normal 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).
|
||||
@@ -1,43 +0,0 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
@@ -40,12 +40,9 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
@@ -64,3 +61,13 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
||||
- 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Intégrations
|
||||
|
||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||
Lesstime s'intègre avec **4 outils externes** pour fluidifier le workflow dev.
|
||||
|
||||
## 🌳 Gitea
|
||||
|
||||
@@ -64,3 +64,29 @@ Sur une tâche avec **scheduled start + end** :
|
||||
|
||||
- **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)
|
||||
|
||||
40
frontend/content/help/10-messaging.md
Normal file
40
frontend/content/help/10-messaging.md
Normal 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*.
|
||||
@@ -351,63 +351,6 @@
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail client",
|
||||
"projects": "Vos projets",
|
||||
"noProjects": "Aucun projet disponible.",
|
||||
"openTickets": "tickets ouverts",
|
||||
"newTicket": "Nouveau ticket",
|
||||
"ticketDetail": "Détail du ticket",
|
||||
"backToProject": "Retour au projet",
|
||||
"submitTicket": "Soumettre le ticket",
|
||||
"ticketCreated": "Ticket soumis avec succès."
|
||||
},
|
||||
"clientTicket": {
|
||||
"title": "Tickets",
|
||||
"new": "Nouveau ticket",
|
||||
"created": "Ticket créé avec succès.",
|
||||
"deleted": "Ticket supprimé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
"statusUpdated": "Statut du ticket mis à jour.",
|
||||
"type": {
|
||||
"bug": "Bug",
|
||||
"improvement": "Amélioration",
|
||||
"other": "Autre"
|
||||
},
|
||||
"status": {
|
||||
"new": "Nouveau",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminé",
|
||||
"rejected": "Rejeté"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
"url": "URL de la page",
|
||||
"urlPlaceholder": "https://example.com/page-concernee",
|
||||
"type": "Type",
|
||||
"project": "Projet"
|
||||
},
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
|
||||
"rejectComment": "Commentaire de rejet",
|
||||
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
|
||||
"linkedTicket": "Lié au ticket client CT-{number}",
|
||||
"description": "Description",
|
||||
"url": "URL (page concernée)",
|
||||
"statusComment": "Commentaire de statut",
|
||||
"statusChanged": "Statut mis à jour",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"linkedTooltip": "Lié au ticket client {number}",
|
||||
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
||||
"noTickets": "Aucun ticket.",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allProjects": "Tous les projets",
|
||||
"submittedBy": "Soumis par",
|
||||
"createdAt": "Créé le",
|
||||
"adminTab": "Tickets client",
|
||||
"selectType": "Type de ticket",
|
||||
"changeStatus": "Changer le statut"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
@@ -528,6 +471,12 @@
|
||||
"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"
|
||||
@@ -608,5 +557,187 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Gestion de projet -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Gestion de projet
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
@@ -53,23 +59,6 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
@@ -103,14 +92,6 @@
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||
icon="mdi:ticket-outline"
|
||||
label="Tickets client"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
@@ -119,13 +100,59 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Section : Absences -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Absences
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
to="/absences"
|
||||
icon="mdi:umbrella-beach-outline"
|
||||
label="Mes absences"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/team-absences"
|
||||
icon="mdi:calendar-account-outline"
|
||||
label="Absences équipe"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Administration (admin only) -->
|
||||
<template v-if="isAdmin">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Administration
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3">
|
||||
@@ -183,12 +210,11 @@ const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||
&& !roles.includes('ROLE_ADMIN')
|
||||
&& !roles.includes('ROLE_USER')
|
||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto" />
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<SidebarLink
|
||||
to="/portal"
|
||||
icon="mdi:folder-outline"
|
||||
label="Mes projets"
|
||||
:collapsed="false"
|
||||
class="border-t border-secondary-500 pt-6"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/"
|
||||
icon="mdi:shield-crown-outline"
|
||||
label="Administration"
|
||||
:collapsed="false"
|
||||
class="mt-2"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const { version } = useAppVersion()
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,16 +10,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
const isClientOnly = auth.isAuthenticated
|
||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
const isProfileRoute = to.path === '/profile'
|
||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||
return navigateTo('/portal')
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2210,9 +2210,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.8/layer-ui-1.4.8.tgz",
|
||||
"integrity": "sha512-ABQmfMqJqKGGnx6kf5KK/XVuKAPWSpRHmLpS9XMg6pUH8kww8o3JoywlrlFkk9xA30zNFaehAtzV7S19E4JTlg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
168
frontend/pages/absences.vue
Normal file
168
frontend/pages/absences.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
||||
<MalioButton
|
||||
:label="$t('absences.newRequest')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="requestDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AbsenceBalanceCards :balances="balances" />
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="$t('absences.filters.allStatuses')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.year"
|
||||
:label="$t('absences.table.year')"
|
||||
:options="yearOptions"
|
||||
:empty-option-label="$t('absences.filters.allYears')"
|
||||
group-class="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="rows.length"
|
||||
:row-clickable="true"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="statusLabel((item as Row).status)"
|
||||
:variant="statusVariant((item as Row).status)"
|
||||
:icon="statusIcon((item as Row).status)"
|
||||
/>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<AbsenceRequestDrawer
|
||||
v-model="requestDrawerOpen"
|
||||
:policies="policies"
|
||||
@created="reload"
|
||||
/>
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailDrawerOpen"
|
||||
:request="selected"
|
||||
:can-cancel="selected?.status === 'pending'"
|
||||
@cancelled="reload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
|
||||
|
||||
useHead({ title: t('absences.title') })
|
||||
|
||||
const balances = ref<AbsenceBalance[]>([])
|
||||
const requests = ref<AbsenceRequest[]>([])
|
||||
const policies = ref<AbsencePolicy[]>([])
|
||||
|
||||
const requestDrawerOpen = ref(false)
|
||||
const detailDrawerOpen = ref(false)
|
||||
const selected = ref<AbsenceRequest | null>(null)
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{ status: AbsenceStatus | null; type: AbsenceType | null; year: number | null }>({
|
||||
status: null,
|
||||
type: null,
|
||||
year: null,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'typeLabelText', label: t('absences.table.type') },
|
||||
{ key: 'periodText', label: t('absences.table.period') },
|
||||
{ key: 'daysText', label: t('absences.table.days') },
|
||||
{ key: 'status', label: t('absences.table.status') },
|
||||
{ key: 'createdAtText', label: t('absences.table.requestedAt') },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('absences.status.pending'), value: 'pending' },
|
||||
{ label: t('absences.status.approved'), value: 'approved' },
|
||||
{ label: t('absences.status.rejected'), value: 'rejected' },
|
||||
{ label: t('absences.status.cancelled'), value: 'cancelled' },
|
||||
]
|
||||
|
||||
const typeOptions = computed(() => policies.value.map(p => ({ label: p.label, value: p.type })))
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const current = new Date().getFullYear()
|
||||
return [current + 1, current, current - 1, current - 2].map(y => ({ label: String(y), value: y }))
|
||||
})
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
requests.value.map(r => ({
|
||||
...r,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
)
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selected.value = item as Row
|
||||
detailDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
// Scope to the current user: the collection endpoint returns every user's
|
||||
// requests for admins, which would leak the whole team into "Mes absences".
|
||||
const userId = useAuthStore().user?.id
|
||||
if (!userId) {
|
||||
requests.value = []
|
||||
return
|
||||
}
|
||||
const f: AbsenceRequestFilters = { user: userId }
|
||||
if (filters.status) f.status = filters.status
|
||||
if (filters.type) f.type = filters.type
|
||||
if (filters.year) f.year = filters.year
|
||||
requests.value = await service.getRequests(f)
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
// Scope balances to the current user: the collection endpoint returns every
|
||||
// user's balance for admins, which would pollute the personal "Mes absences" view.
|
||||
const userId = useAuthStore().user?.id
|
||||
const [bal] = await Promise.all([
|
||||
userId ? service.getBalances({ user: userId }) : Promise.resolve([]),
|
||||
loadRequests(),
|
||||
])
|
||||
balances.value = bal
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.year], loadRequests)
|
||||
|
||||
onMounted(async () => {
|
||||
policies.value = await service.getPolicies()
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
@@ -31,6 +31,7 @@
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +51,7 @@ const tabs = [
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
{ key: 'absences', label: 'Absences' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -20,10 +20,11 @@ const META: Record<string, { title: string, icon: string, accent: string, roles:
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'06-absences': { title: 'Absences', icon: 'mdi:calendar-account-outline', accent: 'from-lime-500 to-green-600', roles: ['admin', 'user'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
'10-messaging': { title: 'Messagerie', icon: 'mdi:email-outline', accent: 'from-teal-500 to-cyan-600', roles: ['admin', 'user'] },
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
@@ -39,7 +40,6 @@ const auth = useAuthStore()
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ const lineOptions = {
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
min-width="!w-48"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -524,7 +524,7 @@ const lineOptions = {
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -533,7 +533,7 @@ const lineOptions = {
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
@@ -54,9 +54,7 @@ async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await navigateTo(isClient ? '/portal' : '/')
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||
// en SPA car la session peut être hydratée après le rendu initial.
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
|
||||
if (isClientOnly.value) {
|
||||
await navigateTo('/portal')
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
@@ -40,11 +25,6 @@ const {
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isClientOnly.value) {
|
||||
router.replace('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ onMounted(async () => {
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -381,7 +381,7 @@ onMounted(async () => {
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -390,7 +390,7 @@ onMounted(async () => {
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -399,7 +399,7 @@ onMounted(async () => {
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -408,7 +408,7 @@ onMounted(async () => {
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -417,7 +417,7 @@ onMounted(async () => {
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -426,7 +426,7 @@ onMounted(async () => {
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('portal.noProjects') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:to="`/portal/projects/${project.id}`"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
const projectId = extractIdFromIri(ticket.project)
|
||||
if (projectId) {
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
} else {
|
||||
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||
}
|
||||
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,282 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
to="/portal"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="isClient"
|
||||
:to="`/portal/projects/${projectId}/new-ticket`"
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
>
|
||||
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||
>
|
||||
<div class="mb-3 flex shrink-0 items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||
{{ col.tickets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div
|
||||
v-for="ticket in col.tickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
|
||||
:draggable="isAdmin"
|
||||
@dragstart="onDragStart(ticket)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="col.tickets.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket detail modal -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="selectedTicket"
|
||||
@refresh="loadTickets"
|
||||
/>
|
||||
|
||||
<!-- Reject comment modal -->
|
||||
<Teleport v-if="rejectModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
|
||||
<textarea
|
||||
v-model="rejectComment"
|
||||
rows="3"
|
||||
class="mt-3 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
:placeholder="$t('clientTicket.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelReject"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('clientTicket.status.rejected')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projectName = ref('')
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-500'
|
||||
case 'in_progress': return 'bg-yellow-500'
|
||||
case 'done': return 'bg-green-500'
|
||||
case 'rejected': return 'bg-red-500'
|
||||
default: return 'bg-neutral-400'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => allStatuses.map(status => ({
|
||||
status,
|
||||
label: t(`clientTicket.status.${status}`),
|
||||
dotClass: statusDotClass(status),
|
||||
tickets: tickets.value.filter(tk => tk.status === status),
|
||||
})))
|
||||
|
||||
// Drag & drop (admin only)
|
||||
const draggedTicket = ref<ClientTicket | null>(null)
|
||||
const dragOverStatus = ref<ClientTicketStatus | null>(null)
|
||||
|
||||
function onDragStart(ticket: ClientTicket) {
|
||||
draggedTicket.value = ticket
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTicket.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: ClientTicketStatus) {
|
||||
if (!draggedTicket.value) return
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(newStatus: ClientTicketStatus) {
|
||||
dragOverStatus.value = null
|
||||
const ticket = draggedTicket.value
|
||||
draggedTicket.value = null
|
||||
|
||||
if (!ticket || ticket.status === newStatus) return
|
||||
|
||||
// Rejected requires a comment
|
||||
if (newStatus === 'rejected') {
|
||||
pendingRejectTicket.value = ticket
|
||||
rejectComment.value = ''
|
||||
rejectModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = newStatus
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const rejectModalOpen = ref(false)
|
||||
const rejectComment = ref('')
|
||||
const pendingRejectTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
function cancelReject() {
|
||||
rejectModalOpen.value = false
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const ticket = pendingRejectTicket.value
|
||||
if (!ticket || !rejectComment.value.trim()) return
|
||||
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = 'rejected'
|
||||
rejectModalOpen.value = false
|
||||
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, {
|
||||
status: 'rejected',
|
||||
statusComment: rejectComment.value.trim(),
|
||||
})
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketList, project] = await Promise.all([
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
projectService.getById(projectId.value),
|
||||
])
|
||||
tickets.value = ticketList
|
||||
projectName.value = project.name
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
|
||||
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
|
||||
<option value="other">{{ $t('clientTicket.type.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
:label="$t('clientTicket.title')"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL (only for bug type) -->
|
||||
<div v-if="form.type === 'bug'" class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('clientTicket.url')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document upload (only after ticket is created) -->
|
||||
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
|
||||
Les documents pourront être ajoutés après la soumission du ticket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<MalioButton
|
||||
:label="$t('portal.submitTicket')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketType } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.newTicket') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
|
||||
const form = reactive({
|
||||
type: 'bug' as ClientTicketType | string,
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
if (!form.description.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await clientTicketService.create({
|
||||
type: form.type as ClientTicketType,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
||||
project: `/api/projects/${projectId.value}`,
|
||||
})
|
||||
await navigateTo(`/portal/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// Toast already shown by useApi
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||
<NuxtLayout name="default">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
@@ -14,17 +14,18 @@
|
||||
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<label
|
||||
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('profile.changeAvatar')"
|
||||
@click="avatarInput?.click()"
|
||||
/>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
{{ $t('profile.changeAvatar') }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
@@ -39,7 +40,6 @@
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
v-if="!isClientOnly"
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
@@ -134,10 +134,6 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
@@ -145,6 +141,7 @@ const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
Tickets client
|
||||
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option :value="null">Tous les statuts</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<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"
|
||||
@click.stop="onDelete(ticket)"
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<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>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Tickets client' })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(ticket: ClientTicket) {
|
||||
await clientTicketService.remove(ticket.id)
|
||||
await loadTickets()
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
])
|
||||
project.value = p
|
||||
tickets.value = t
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -47,7 +47,7 @@
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -56,7 +56,7 @@
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -75,7 +75,7 @@
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -84,7 +84,7 @@
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
479
frontend/pages/team-absences.vue
Normal file
479
frontend/pages/team-absences.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t("absences.teamTitle") }}
|
||||
</h1>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.pending") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-amber-600">
|
||||
{{ kpis.pending }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.todayAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.today }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.weekAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.week }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<!-- Requests -->
|
||||
<template #requests>
|
||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allStatuses')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allTypes')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.user"
|
||||
:label="$t('absences.table.employee')"
|
||||
:options="employeeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allEmployees')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="requestColumns"
|
||||
:items="requestRows"
|
||||
:total-items="requestRows.length"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="
|
||||
statusLabel((item as RequestRow).status)
|
||||
"
|
||||
:variant="
|
||||
statusVariant((item as RequestRow).status)
|
||||
"
|
||||
:icon="statusIcon((item as RequestRow).status)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div
|
||||
v-if="(item as RequestRow).status === 'pending'"
|
||||
class="flex gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:check"
|
||||
:aria-label="$t('absences.review.approve')"
|
||||
button-class="!bg-green-100 !text-green-700"
|
||||
:icon-size="18"
|
||||
@click="approve(item as RequestRow)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
:aria-label="$t('absences.review.reject')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="openReject(item as RequestRow)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-neutral-300">—</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Calendar -->
|
||||
<template #calendar>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<AbsenceCalendar
|
||||
:absences="calendarAbsences"
|
||||
@range-change="loadCalendar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balances -->
|
||||
<template #balances>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="balanceColumns"
|
||||
:items="balanceRows"
|
||||
:total-items="balanceRows.length"
|
||||
:row-clickable="false"
|
||||
:empty-message="$t('absences.noBalance')"
|
||||
>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end">
|
||||
<MalioButton
|
||||
:label="
|
||||
$t(
|
||||
'absences.admin.balancesTable.adjust',
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil"
|
||||
icon-position="left"
|
||||
button-class="w-auto"
|
||||
@click="openAdjust(item as BalanceRow)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
</MalioTabList>
|
||||
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailOpen"
|
||||
:request="selectedRequest"
|
||||
:can-cancel="
|
||||
selectedRequest?.status === 'pending' ||
|
||||
selectedRequest?.status === 'approved'
|
||||
"
|
||||
@cancelled="reloadRequests"
|
||||
/>
|
||||
<AbsenceRejectDrawer
|
||||
v-model="rejectOpen"
|
||||
:request="selectedRequest"
|
||||
@rejected="reloadRequests"
|
||||
/>
|
||||
<AbsenceBalanceAdjustDrawer
|
||||
v-model="adjustOpen"
|
||||
:balance="selectedBalance"
|
||||
@adjusted="loadBalances"
|
||||
/>
|
||||
<EmployeeDrawer
|
||||
v-model="employeeDrawerOpen"
|
||||
:user="selectedEmployee"
|
||||
@saved="loadEmployees"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AbsenceBalance,
|
||||
AbsenceRequest,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from "~/services/dto/absence";
|
||||
import {
|
||||
useAbsenceService,
|
||||
type AbsenceRequestFilters,
|
||||
} from "~/services/absences";
|
||||
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
|
||||
import { useUserService } from "~/services/users";
|
||||
import type { UserData } from "~/services/dto/user-data";
|
||||
|
||||
definePageMeta({ middleware: ["admin"] });
|
||||
|
||||
type RequestRow = AbsenceRequest & {
|
||||
employeeText: string;
|
||||
typeLabelText: string;
|
||||
periodText: string;
|
||||
daysText: string;
|
||||
createdAtText: string;
|
||||
};
|
||||
type BalanceRow = AbsenceBalance & {
|
||||
employeeText: string;
|
||||
availableText: string;
|
||||
};
|
||||
type EmployeeRow = UserData & {
|
||||
contractText: string;
|
||||
cpTakenText: string;
|
||||
cpRemainingText: string;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const service = useAbsenceService();
|
||||
const {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
statusIcon,
|
||||
formatRange,
|
||||
formatDays,
|
||||
formatDate,
|
||||
} = useAbsenceHelpers();
|
||||
|
||||
useHead({ title: t("absences.teamTitle") });
|
||||
|
||||
const activeTab = ref("requests");
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
const requests = ref<AbsenceRequest[]>([]);
|
||||
const balances = ref<AbsenceBalance[]>([]);
|
||||
const calendarAbsences = ref<AbsenceRequest[]>([]);
|
||||
|
||||
const employees = ref<UserData[]>([]);
|
||||
const employeeDrawerOpen = ref(false);
|
||||
const selectedEmployee = ref<UserData | null>(null);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const rejectOpen = ref(false);
|
||||
const adjustOpen = ref(false);
|
||||
const selectedRequest = ref<AbsenceRequest | null>(null);
|
||||
const selectedBalance = ref<AbsenceBalance | null>(null);
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{
|
||||
status: AbsenceStatus | null;
|
||||
type: AbsenceType | null;
|
||||
user: number | null;
|
||||
}>({
|
||||
status: null,
|
||||
type: null,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t("absences.status.pending"), value: "pending" },
|
||||
{ label: t("absences.status.approved"), value: "approved" },
|
||||
{ label: t("absences.status.rejected"), value: "rejected" },
|
||||
{ label: t("absences.status.cancelled"), value: "cancelled" },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t("absences.types.cp"), value: "cp" },
|
||||
{ label: t("absences.types.mariage_pacs"), value: "mariage_pacs" },
|
||||
{ label: t("absences.types.conge_parental"), value: "conge_parental" },
|
||||
{ label: t("absences.types.deces"), value: "deces" },
|
||||
{ label: t("absences.types.maladie"), value: "maladie" },
|
||||
];
|
||||
|
||||
const employeeOptions = computed(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const r of requests.value) map.set(r.user.id, r.user.username);
|
||||
for (const b of balances.value) map.set(b.user.id, b.user.username);
|
||||
return [...map.entries()].map(([value, label]) => ({ value, label }));
|
||||
});
|
||||
|
||||
const requestColumns = [
|
||||
{ key: "employeeText", label: t("absences.table.employee") },
|
||||
{ key: "typeLabelText", label: t("absences.table.type") },
|
||||
{ key: "periodText", label: t("absences.table.period") },
|
||||
{ key: "daysText", label: t("absences.table.days") },
|
||||
{ key: "status", label: t("absences.table.status") },
|
||||
{ key: "createdAtText", label: t("absences.table.requestedAt") },
|
||||
{ key: "actions", label: t("absences.table.actions") },
|
||||
];
|
||||
|
||||
const requestRows = computed<RequestRow[]>(() =>
|
||||
requests.value.map((r) => ({
|
||||
...r,
|
||||
employeeText: r.user.username,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
);
|
||||
|
||||
const balanceColumns = [
|
||||
{ key: "employeeText", label: t("absences.admin.balancesTable.employee") },
|
||||
{ key: "label", label: t("absences.admin.balancesTable.type") },
|
||||
{ key: "period", label: t("absences.admin.balancesTable.period") },
|
||||
{ key: "acquired", label: t("absences.admin.balancesTable.acquired") },
|
||||
{ key: "acquiring", label: t("absences.admin.balancesTable.acquiring") },
|
||||
{ key: "taken", label: t("absences.admin.balancesTable.taken") },
|
||||
{ key: "pending", label: t("absences.admin.balancesTable.pending") },
|
||||
{
|
||||
key: "availableText",
|
||||
label: t("absences.admin.balancesTable.available"),
|
||||
},
|
||||
{ key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const balanceRows = computed<BalanceRow[]>(() =>
|
||||
balances.value.map((b) => ({
|
||||
...b,
|
||||
employeeText: b.user.username,
|
||||
availableText: formatDays(b.available),
|
||||
})),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const kpis = computed(() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date();
|
||||
const day = (now.getDay() + 6) % 7;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() - day);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const mondayStr = monday.toISOString().slice(0, 10);
|
||||
const sundayStr = sunday.toISOString().slice(0, 10);
|
||||
|
||||
const approved = requests.value.filter((r) => r.status === "approved");
|
||||
const todayUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= today &&
|
||||
r.endDate.slice(0, 10) >= today,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
const weekUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= sundayStr &&
|
||||
r.endDate.slice(0, 10) >= mondayStr,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
|
||||
return {
|
||||
pending: requests.value.filter((r) => r.status === "pending").length,
|
||||
today: todayUsers.size,
|
||||
week: weekUsers.size,
|
||||
};
|
||||
});
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selectedRequest.value = item as RequestRow;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function openReject(row: RequestRow) {
|
||||
selectedRequest.value = row;
|
||||
rejectOpen.value = true;
|
||||
}
|
||||
|
||||
function openAdjust(row: BalanceRow) {
|
||||
selectedBalance.value = row;
|
||||
adjustOpen.value = true;
|
||||
}
|
||||
|
||||
async function approve(row: RequestRow) {
|
||||
await service.approve(row.id);
|
||||
await reloadRequests();
|
||||
}
|
||||
|
||||
async function reloadRequests() {
|
||||
const f: AbsenceRequestFilters = {};
|
||||
if (filters.status) f.status = filters.status;
|
||||
if (filters.type) f.type = filters.type;
|
||||
if (filters.user) f.user = filters.user;
|
||||
requests.value = await service.getRequests(f);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
balances.value = await service.getBalances();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadCalendar(from: string, to: string) {
|
||||
calendarAbsences.value = await service.getCalendar(from, to);
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.user], reloadRequests);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
|
||||
:deep([role="tab"]) {
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,7 @@
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
137
frontend/services/absences.ts
Normal file
137
frontend/services/absences.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
AbsenceBalance,
|
||||
AbsencePolicy,
|
||||
AbsencePolicyWrite,
|
||||
AbsencePreviewPayload,
|
||||
AbsencePreviewResult,
|
||||
AbsenceRequest,
|
||||
AbsenceRequestWrite,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from './dto/absence'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export type AbsenceRequestFilters = {
|
||||
status?: AbsenceStatus
|
||||
type?: AbsenceType
|
||||
year?: number
|
||||
user?: number
|
||||
}
|
||||
|
||||
export function useAbsenceService() {
|
||||
const api = useApi()
|
||||
|
||||
// --- Requests ---
|
||||
|
||||
async function getRequests(filters: AbsenceRequestFilters = {}): Promise<AbsenceRequest[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.status) query.status = filters.status
|
||||
if (filters.type) query.type = filters.type
|
||||
if (filters.year) query.year = filters.year
|
||||
if (filters.user) query.user = `/api/users/${filters.user}`
|
||||
const data = await api.get<HydraCollection<AbsenceRequest>>('/absence_requests', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getRequest(id: number): Promise<AbsenceRequest> {
|
||||
return api.get<AbsenceRequest>(`/absence_requests/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: AbsenceRequestWrite): Promise<AbsenceRequest> {
|
||||
return api.post<AbsenceRequest>('/absence_requests', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function preview(payload: AbsencePreviewPayload): Promise<AbsencePreviewResult> {
|
||||
return api.post<AbsencePreviewResult>('/absence_requests/preview', payload as Record<string, unknown>, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function approve(id: number): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/approve`, {}, {
|
||||
toastSuccessKey: 'absences.toast.approved',
|
||||
})
|
||||
}
|
||||
|
||||
async function reject(id: number, rejectionReason: string): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/reject`, { rejectionReason }, {
|
||||
toastSuccessKey: 'absences.toast.rejected',
|
||||
})
|
||||
}
|
||||
|
||||
async function cancel(id: number): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/cancel`, {}, {
|
||||
toastSuccessKey: 'absences.toast.cancelled',
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadJustification(id: number, file: File): Promise<AbsenceRequest> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<AbsenceRequest>(`/absence_requests/${id}/justificatif`, form as unknown as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.justificationUploaded',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Balances ---
|
||||
|
||||
async function getBalances(filters: { user?: number; period?: string; type?: AbsenceType } = {}): Promise<AbsenceBalance[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.user) query.user = `/api/users/${filters.user}`
|
||||
if (filters.period) query.period = filters.period
|
||||
if (filters.type) query.type = filters.type
|
||||
const data = await api.get<HydraCollection<AbsenceBalance>>('/absence_balances', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function adjustBalance(id: number, payload: { acquired?: number; acquiring?: number; taken?: number }): Promise<AbsenceBalance> {
|
||||
return api.patch<AbsenceBalance>(`/absence_balances/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.balanceAdjusted',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Policies ---
|
||||
|
||||
async function getPolicies(): Promise<AbsencePolicy[]> {
|
||||
const data = await api.get<HydraCollection<AbsencePolicy>>('/absence_policies')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function updatePolicy(id: number, payload: AbsencePolicyWrite): Promise<AbsencePolicy> {
|
||||
return api.patch<AbsencePolicy>(`/absence_policies/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.policyUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Admin calendar ---
|
||||
|
||||
async function getCalendar(from: string, to: string): Promise<AbsenceRequest[]> {
|
||||
return api.get<AbsenceRequest[]>('/admin/absences/calendar', { from, to })
|
||||
}
|
||||
|
||||
// --- Public holidays (computed server-side) ---
|
||||
|
||||
async function getPublicHolidays(from: string, to: string): Promise<Record<string, string>> {
|
||||
return api.get<Record<string, string>>('/public_holidays', { from, to }, { toast: false })
|
||||
}
|
||||
|
||||
return {
|
||||
getRequests,
|
||||
getRequest,
|
||||
create,
|
||||
preview,
|
||||
approve,
|
||||
reject,
|
||||
cancel,
|
||||
uploadJustification,
|
||||
getBalances,
|
||||
adjustBalance,
|
||||
getPolicies,
|
||||
updatePolicy,
|
||||
getCalendar,
|
||||
getPublicHolidays,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useClientTicketService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (params?.project) query.project = `/api/projects/${params.project}`
|
||||
if (params?.status) query.status = params.status
|
||||
if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}`
|
||||
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<ClientTicket> {
|
||||
return api.get<ClientTicket>(`/client_tickets/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
|
||||
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'portal.ticketCreated',
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.statusUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, updateStatus, remove }
|
||||
}
|
||||
93
frontend/services/dto/absence.ts
Normal file
93
frontend/services/dto/absence.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
|
||||
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
|
||||
export type HalfDay = 'matin' | 'apres_midi'
|
||||
|
||||
export type AbsenceUserRef = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type AbsenceRequest = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay: HalfDay | null
|
||||
endHalfDay: HalfDay | null
|
||||
countedDays: number
|
||||
reason: string | null
|
||||
justificationFileName: string | null
|
||||
justificationUrl: string | null
|
||||
status: AbsenceStatus
|
||||
rejectionReason: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
reviewedBy: AbsenceUserRef | null
|
||||
}
|
||||
|
||||
export type AbsenceRequestWrite = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AbsenceBalance = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
period: string
|
||||
acquired: number
|
||||
acquiring: number
|
||||
acquiredTotal: number
|
||||
taken: number
|
||||
pending: number
|
||||
available: number
|
||||
}
|
||||
|
||||
export type AbsencePolicy = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
type: AbsenceType
|
||||
label: string
|
||||
daysPerYear: number | null
|
||||
daysPerEvent: number | null
|
||||
justificationRequired: boolean
|
||||
noticeDays: number
|
||||
countWorkingDaysOnly: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type AbsencePolicyWrite = {
|
||||
daysPerYear?: number | null
|
||||
daysPerEvent?: number | null
|
||||
justificationRequired?: boolean
|
||||
noticeDays?: number
|
||||
countWorkingDaysOnly?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type AbsencePreviewPayload = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
}
|
||||
|
||||
export type AbsencePreviewResult = {
|
||||
countedDays: number
|
||||
period: string | null
|
||||
available: number | null
|
||||
projectedAvailable: number | null
|
||||
justificationRequired: boolean
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type ClientTicketType = 'bug' | 'improvement' | 'other'
|
||||
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
|
||||
|
||||
export type ClientTicket = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
number: number
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url: string | null
|
||||
status: ClientTicketStatus
|
||||
statusComment: string | null
|
||||
project: string
|
||||
submittedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: TaskDocument[]
|
||||
}
|
||||
|
||||
export type ClientTicketWrite = {
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url?: string | null
|
||||
project: string
|
||||
}
|
||||
|
||||
export type ClientTicketStatusUpdate = {
|
||||
status: ClientTicketStatus
|
||||
statusComment?: string | null
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export type Notification = {
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
relatedTicket: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ export type Task = {
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
clientTicket: {
|
||||
id: number
|
||||
number: number
|
||||
type: string
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
@@ -61,7 +54,6 @@ export type TaskWrite = {
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
clientTicket?: string | null
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import type { Project } from './project'
|
||||
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
||||
|
||||
export type UserData = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
username: string
|
||||
roles: string[]
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
username: string
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
client?: string | null
|
||||
allowedProjects?: string[]
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
}
|
||||
|
||||
@@ -31,17 +31,6 @@ export function useTaskDocumentService() {
|
||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||
}
|
||||
|
||||
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
|
||||
}
|
||||
|
||||
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
@@ -52,5 +41,5 @@ export function useTaskDocumentService() {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
|
||||
return { getByTask, upload, remove, getDownloadUrl }
|
||||
}
|
||||
|
||||
3
makefile
3
makefile
@@ -49,6 +49,9 @@ composer-install:
|
||||
|
||||
build-nuxtJS:
|
||||
# $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local
|
||||
# Nettoie dist en root au cas où un ancien build l'aurait laissé en root:root
|
||||
# (sinon le `rm -rf dist` de `build:dist`, exécuté en www-data, échoue avec "Permission denied")
|
||||
-$(EXEC_PHP_ROOT) rm -rf frontend/dist
|
||||
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
|
||||
|
||||
dev-nuxt:
|
||||
|
||||
42
migrations/Version20260521123520.php
Normal file
42
migrations/Version20260521123520.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: create the absence_policy table.
|
||||
*/
|
||||
final class Version20260521123520 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create absence_policy table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE absence_policy (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
days_per_year DOUBLE PRECISION DEFAULT NULL,
|
||||
days_per_event DOUBLE PRECISION DEFAULT NULL,
|
||||
justification_required BOOLEAN NOT NULL,
|
||||
notice_days INT NOT NULL,
|
||||
count_working_days_only BOOLEAN NOT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_absence_policy_type ON absence_policy (type)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE absence_policy');
|
||||
}
|
||||
}
|
||||
49
migrations/Version20260521123521.php
Normal file
49
migrations/Version20260521123521.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: create the absence_balance table.
|
||||
*/
|
||||
final class Version20260521123521 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create absence_balance table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE absence_balance (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
period VARCHAR(16) NOT NULL,
|
||||
acquired DOUBLE PRECISION NOT NULL,
|
||||
taken DOUBLE PRECISION NOT NULL,
|
||||
pending DOUBLE PRECISION NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_65723A76A76ED395 ON absence_balance (user_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_absence_balance_user_type_period ON absence_balance (user_id, type, period)');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
absence_balance
|
||||
ADD
|
||||
CONSTRAINT FK_65723A76A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE absence_balance DROP CONSTRAINT FK_65723A76A76ED395');
|
||||
$this->addSql('DROP TABLE absence_balance');
|
||||
}
|
||||
}
|
||||
64
migrations/Version20260521123522.php
Normal file
64
migrations/Version20260521123522.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: create the absence_request table.
|
||||
*/
|
||||
final class Version20260521123522 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create absence_request table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE absence_request (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
start_half_day VARCHAR(16) DEFAULT NULL,
|
||||
end_half_day VARCHAR(16) DEFAULT NULL,
|
||||
counted_days DOUBLE PRECISION NOT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
justification_file_name VARCHAR(255) DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
rejection_reason TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
reviewed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
user_id INT NOT NULL,
|
||||
reviewed_by_id INT DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_F211AA17A76ED395 ON absence_request (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_F211AA17FC6B21F1 ON absence_request (reviewed_by_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
absence_request
|
||||
ADD
|
||||
CONSTRAINT FK_F211AA17A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
absence_request
|
||||
ADD
|
||||
CONSTRAINT FK_F211AA17FC6B21F1 FOREIGN KEY (reviewed_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17A76ED395');
|
||||
$this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17FC6B21F1');
|
||||
$this->addSql('DROP TABLE absence_request');
|
||||
}
|
||||
}
|
||||
59
migrations/Version20260521123523.php
Normal file
59
migrations/Version20260521123523.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: add HR fields to the user table.
|
||||
*
|
||||
* Columns are created with DEFAULTs so the migration applies cleanly on an
|
||||
* already-populated user table (production).
|
||||
*/
|
||||
final class Version20260521123523 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add HR / absence fields to user';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD is_employee BOOLEAN NOT NULL DEFAULT false');
|
||||
$this->addSql('ALTER TABLE "user" ADD hire_date DATE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD end_date DATE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD contract_type VARCHAR(16) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD work_time_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0');
|
||||
$this->addSql('ALTER TABLE "user" ADD annual_leave_days DOUBLE PRECISION NOT NULL DEFAULT 25.0');
|
||||
$this->addSql("ALTER TABLE \"user\" ADD reference_period_start VARCHAR(5) NOT NULL DEFAULT '06-01'");
|
||||
$this->addSql('ALTER TABLE "user" ADD initial_leave_balance DOUBLE PRECISION NOT NULL DEFAULT 0');
|
||||
$this->addSql('ALTER TABLE "user" ADD family_situation VARCHAR(16) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD nb_children INT NOT NULL DEFAULT 0');
|
||||
|
||||
// Defaults were only needed to backfill existing rows; the ORM mapping
|
||||
// carries no DB default, so drop them to keep the schema in sync.
|
||||
$this->addSql('ALTER TABLE "user" ALTER is_employee DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE "user" ALTER work_time_ratio DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE "user" ALTER annual_leave_days DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE "user" ALTER reference_period_start DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE "user" ALTER initial_leave_balance DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE "user" ALTER nb_children DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP is_employee');
|
||||
$this->addSql('ALTER TABLE "user" DROP hire_date');
|
||||
$this->addSql('ALTER TABLE "user" DROP end_date');
|
||||
$this->addSql('ALTER TABLE "user" DROP contract_type');
|
||||
$this->addSql('ALTER TABLE "user" DROP work_time_ratio');
|
||||
$this->addSql('ALTER TABLE "user" DROP annual_leave_days');
|
||||
$this->addSql('ALTER TABLE "user" DROP reference_period_start');
|
||||
$this->addSql('ALTER TABLE "user" DROP initial_leave_balance');
|
||||
$this->addSql('ALTER TABLE "user" DROP family_situation');
|
||||
$this->addSql('ALTER TABLE "user" DROP nb_children');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260521160000.php
Normal file
30
migrations/Version20260521160000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: track the last accrued month on absence_balance to make
|
||||
* the monthly paid-leave accrual idempotent.
|
||||
*/
|
||||
final class Version20260521160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add last_accrued_month to absence_balance';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE absence_balance ADD last_accrued_month VARCHAR(7) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE absence_balance DROP last_accrued_month');
|
||||
}
|
||||
}
|
||||
39
migrations/Version20260522090000.php
Normal file
39
migrations/Version20260522090000.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Absence management: split the paid-leave balance into "acquired" (Congés N-1,
|
||||
* finalized and available) and "acquiring" (Congés N, "en cours d'acquisition"),
|
||||
* mirroring the two columns on a French payslip.
|
||||
*
|
||||
* Existing paid-leave balances were filled solely by the monthly accrual, which
|
||||
* credited `acquired`; those days actually belong to the current period's
|
||||
* "en cours d'acquisition", so they are moved to `acquiring` on the way up.
|
||||
*/
|
||||
final class Version20260522090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add acquiring (en cours d\'acquisition) to absence_balance and reclassify existing paid-leave days';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE absence_balance ADD acquiring DOUBLE PRECISION DEFAULT 0 NOT NULL');
|
||||
$this->addSql("UPDATE absence_balance SET acquiring = acquired, acquired = 0 WHERE type = 'cp'");
|
||||
$this->addSql('ALTER TABLE absence_balance ALTER acquiring DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Fold the in-progress days back into acquired before dropping the column.
|
||||
$this->addSql("UPDATE absence_balance SET acquired = acquired + acquiring WHERE type = 'cp'");
|
||||
$this->addSql('ALTER TABLE absence_balance DROP acquiring');
|
||||
}
|
||||
}
|
||||
102
migrations/Version20260522110000.php
Normal file
102
migrations/Version20260522110000.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Suppression de la feature « portail client » : table client_ticket,
|
||||
* table user_allowed_projects, colonne user.client_id, et toutes les
|
||||
* relations vers client_ticket (task, task_document, time_entry, notification).
|
||||
*/
|
||||
final class Version20260522110000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop client portal feature (client_ticket, user_allowed_projects, related columns)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// time_entry → client_ticket
|
||||
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT IF EXISTS FK_6E537C0C9B2097DD');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_6E537C0C9B2097DD');
|
||||
$this->addSql('ALTER TABLE time_entry DROP COLUMN IF EXISTS client_ticket_id');
|
||||
|
||||
// task → client_ticket
|
||||
$this->addSql('ALTER TABLE task DROP CONSTRAINT IF EXISTS FK_527EDB259B2097DD');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_527EDB259B2097DD');
|
||||
$this->addSql('ALTER TABLE task DROP COLUMN IF EXISTS client_ticket_id');
|
||||
|
||||
// notification → client_ticket
|
||||
$this->addSql('ALTER TABLE notification DROP CONSTRAINT IF EXISTS FK_BF5476CAD8C11BC9');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_BF5476CAD8C11BC9');
|
||||
$this->addSql('ALTER TABLE notification DROP COLUMN IF EXISTS related_ticket_id');
|
||||
|
||||
// task_document → client_ticket : les documents rattachés uniquement à un
|
||||
// ticket (sans tâche) disparaissent avec la feature.
|
||||
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT IF EXISTS FK_98A9603A9B2097DD');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_98A9603A9B2097DD');
|
||||
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT IF EXISTS chk_document_owner');
|
||||
$this->addSql('DELETE FROM task_document WHERE task_id IS NULL');
|
||||
$this->addSql('ALTER TABLE task_document DROP COLUMN IF EXISTS client_ticket_id');
|
||||
$this->addSql('ALTER TABLE task_document ALTER COLUMN task_id SET NOT NULL');
|
||||
|
||||
// user → client
|
||||
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT IF EXISTS FK_8D93D64919EB6921');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_8D93D64919EB6921');
|
||||
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS client_id');
|
||||
|
||||
// tables dédiées au portail
|
||||
$this->addSql('DROP TABLE IF EXISTS user_allowed_projects');
|
||||
$this->addSql('DROP TABLE IF EXISTS client_ticket');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Recreate client_ticket
|
||||
$this->addSql('CREATE TABLE client_ticket (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, number INT NOT NULL, type VARCHAR(20) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, url VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, status_comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, project_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_C206E610166D1F9C ON client_ticket (project_id)');
|
||||
$this->addSql('CREATE INDEX IDX_C206E61079F7D87D ON client_ticket (submitted_by_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_client_ticket_project_number ON client_ticket (project_id, number)');
|
||||
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61079F7D87D FOREIGN KEY (submitted_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
|
||||
// Recreate user_allowed_projects
|
||||
$this->addSql('CREATE TABLE user_allowed_projects (user_id INT NOT NULL, project_id INT NOT NULL, PRIMARY KEY (user_id, project_id))');
|
||||
$this->addSql('CREATE INDEX IDX_B3E0FC97A76ED395 ON user_allowed_projects (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_B3E0FC97166D1F9C ON user_allowed_projects (project_id)');
|
||||
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
|
||||
|
||||
// user.client_id
|
||||
$this->addSql('ALTER TABLE "user" ADD client_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D64919EB6921 FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_8D93D64919EB6921 ON "user" (client_id)');
|
||||
|
||||
// task_document.client_ticket_id
|
||||
$this->addSql('ALTER TABLE task_document ALTER COLUMN task_id DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE task_document ADD client_ticket_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_98A9603A9B2097DD ON task_document (client_ticket_id)');
|
||||
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_document_owner CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)');
|
||||
|
||||
// notification.related_ticket_id
|
||||
$this->addSql('ALTER TABLE notification ADD related_ticket_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAD8C11BC9 FOREIGN KEY (related_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_BF5476CAD8C11BC9 ON notification (related_ticket_id)');
|
||||
|
||||
// task.client_ticket_id
|
||||
$this->addSql('ALTER TABLE task ADD client_ticket_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB259B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_527EDB259B2097DD ON task (client_ticket_id)');
|
||||
|
||||
// time_entry.client_ticket_id
|
||||
$this->addSql('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
|
||||
}
|
||||
}
|
||||
33
migrations/Version20260522140000.php
Normal file
33
migrations/Version20260522140000.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* RGPD / minimisation : suppression des colonnes user.family_situation et
|
||||
* user.nb_children. Ces données (situation familiale, nombre d'enfants)
|
||||
* étaient collectées et exposées mais n'étaient utilisées par aucun calcul.
|
||||
*/
|
||||
final class Version20260522140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop unused HR fields user.family_situation and user.nb_children (data minimisation)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS family_situation');
|
||||
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS nb_children');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD family_situation VARCHAR(16) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD nb_children INT NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
151
src/Command/AccrueLeaveCommand.php
Normal file
151
src/Command/AccrueLeaveCommand.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Repository\AbsenceBalanceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\AbsenceBalanceService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function preg_match;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Monthly paid-leave accrual. For each active employee it credits one twelfth
|
||||
* of their yearly entitlement (prorated by work-time ratio) to the current
|
||||
* reference-period balance. Idempotent per month thanks to lastAccruedMonth,
|
||||
* and it seeds the initial balance when the period balance is first created.
|
||||
*
|
||||
* Intended to run on the 1st of each month (cron). Notifications are out of
|
||||
* scope for now.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:absences:accrue-leave',
|
||||
description: 'Credit the monthly paid-leave accrual to every active employee',
|
||||
)]
|
||||
class AccrueLeaveCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly AbsenceBalanceRepository $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('month', null, InputOption::VALUE_REQUIRED, 'Target month (YYYY-MM), defaults to the current month')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Compute and display without persisting')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$monthOpt = $input->getOption('month');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$firstDay = $monthOpt
|
||||
? new DateTimeImmutable($monthOpt.'-01')
|
||||
: new DateTimeImmutable('first day of this month');
|
||||
} catch (Exception) {
|
||||
$io->error('Invalid --month, expected format YYYY-MM.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$firstDay = $firstDay->setTime(0, 0);
|
||||
$lastDay = $firstDay->modify('last day of this month');
|
||||
$monthKey = $firstDay->format('Y-m');
|
||||
|
||||
$io->title(sprintf('Acquisition CP — %s%s', $monthKey, $dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$employees = $this->userRepository->findActiveEmployees($lastDay);
|
||||
if ([] === $employees) {
|
||||
$io->warning('Aucun salarié actif pour ce mois.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$accrued = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($employees as $user) {
|
||||
$rate = ($user->getAnnualLeaveDays() / 12) * $user->getWorkTimeRatio();
|
||||
$period = $this->balanceService->periodFor($user, AbsenceType::PaidLeave, $firstDay);
|
||||
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period);
|
||||
$isNew = null === $balance;
|
||||
|
||||
if ($isNew) {
|
||||
$balance = $this->balanceService->getOrCreateBalance($user, AbsenceType::PaidLeave, $period);
|
||||
// On a new period, the previous period's "en cours d'acquisition" (N)
|
||||
// becomes this period's acquired (N-1). At roll-out (no prior balance)
|
||||
// seed the configured initial balance instead.
|
||||
$previousPeriod = self::previousPeriod($period);
|
||||
$previousBalance = null !== $previousPeriod
|
||||
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
|
||||
: null;
|
||||
$balance->setAcquired(
|
||||
null !== $previousBalance ? $previousBalance->getAcquiring() : $user->getInitialLeaveBalance(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($monthKey === $balance->getLastAccruedMonth()) {
|
||||
++$skipped;
|
||||
$rows[] = [$user->getUsername(), $period, number_format($balance->getAcquired(), 2), number_format($balance->getAcquiring(), 2), 'déjà fait'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance->setAcquiring($balance->getAcquiring() + $rate);
|
||||
$balance->setLastAccruedMonth($monthKey);
|
||||
++$accrued;
|
||||
|
||||
$seeded = $isNew && (null !== self::previousPeriod($period) || $user->getInitialLeaveBalance() > 0);
|
||||
$rows[] = [
|
||||
$user->getUsername(),
|
||||
$period,
|
||||
number_format($balance->getAcquired(), 2),
|
||||
number_format($balance->getAcquiring(), 2),
|
||||
sprintf('+%s%s', number_format($rate, 2), $seeded && $balance->getAcquired() > 0 ? ' (N-1 reporté)' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$io->table(['Salarié', 'Période', 'Acquis (N-1)', 'En cours (N)', 'Action'], $rows);
|
||||
$io->success(sprintf('%d crédité(s), %d ignoré(s)%s.', $accrued, $skipped, $dryRun ? ' (dry-run, rien enregistré)' : ''));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Previous reference period for a "YYYY-YYYY" paid-leave period, or null. */
|
||||
private static function previousPeriod(string $period): ?string
|
||||
{
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{4})$/', $period, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%d-%d', (int) $m[1] - 1, (int) $m[2] - 1);
|
||||
}
|
||||
}
|
||||
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Mail\MimeHeaderDecoder;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:mail:redecode-headers',
|
||||
description: 'Re-décode les sujets et noms d\'expéditeur encodés en MIME (RFC 2047) déjà stockés en base',
|
||||
)]
|
||||
final class MailRedecodeHeadersCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Affiche les changements sans écrire en base',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$isDryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$messages = $this->messageRepository->findAll();
|
||||
$io->text(sprintf('%d message(s) à examiner...', count($messages)));
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$newSubject = MimeHeaderDecoder::decode($message->getSubject());
|
||||
$newFromName = MimeHeaderDecoder::decode($message->getFromName());
|
||||
|
||||
$hasChange = $newSubject !== $message->getSubject() || $newFromName !== $message->getFromName();
|
||||
|
||||
if (!$hasChange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->text(sprintf(' - #%d : "%s" → "%s"', $message->getId(), (string) $message->getSubject(), (string) $newSubject));
|
||||
}
|
||||
|
||||
if (!$isDryRun) {
|
||||
$message->setSubject($newSubject);
|
||||
$message->setFromName($newFromName);
|
||||
}
|
||||
|
||||
++$changed;
|
||||
}
|
||||
|
||||
if (!$isDryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'%s%d en-tête(s) re-décodé(s).',
|
||||
$isDryRun ? '[dry-run] ' : '',
|
||||
$changed,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
src/Controller/Absence/AbsenceCalendarController.php
Normal file
43
src/Controller/Absence/AbsenceCalendarController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Absence;
|
||||
|
||||
use App\Repository\AbsenceRequestRepository;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin calendar view: all pending/approved absences overlapping a date range.
|
||||
*/
|
||||
class AbsenceCalendarController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepository $requestRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$fromRaw = (string) $request->query->get('from', '');
|
||||
$toRaw = (string) $request->query->get('to', '');
|
||||
|
||||
if ('' === $fromRaw || '' === $toRaw) {
|
||||
throw new UnprocessableEntityHttpException('Query parameters "from" and "to" are required.');
|
||||
}
|
||||
|
||||
$from = new DateTimeImmutable($fromRaw);
|
||||
$to = new DateTimeImmutable($toRaw);
|
||||
|
||||
$absences = $this->requestRepository->findInRange($from, $to);
|
||||
|
||||
return $this->json($absences, context: ['groups' => ['absence_request:read']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Absence;
|
||||
|
||||
use App\Entity\AbsenceRequest;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Streams the justification file of an absence request. Owner or admin only.
|
||||
*/
|
||||
class AbsenceJustificationDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id): BinaryFileResponse
|
||||
{
|
||||
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
|
||||
if (null === $absence) {
|
||||
throw new NotFoundHttpException('Absence request not found.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException('You do not have access to this file.');
|
||||
}
|
||||
|
||||
$fileName = $absence->getJustificationFileName();
|
||||
if (null === $fileName) {
|
||||
throw new NotFoundHttpException('No justification file for this request.');
|
||||
}
|
||||
|
||||
$filePath = $this->uploadDir.'/'.$fileName;
|
||||
if (!file_exists($filePath)) {
|
||||
throw new NotFoundHttpException('File not found on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
|
||||
$disposition = (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType)
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response->setContentDisposition($disposition, $fileName);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user