feat(absences) : avancement module absences + suppression du portail client
Deux lots regroupés sur la branche feat/absence-management. Suppression complète du portail client : - retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER - supprime l'entité ClientTicket (+ repo, states, relations), User.client et User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc ROLE_CLIENT de MailAccessChecker - front : pages /portal, layout portal, composants client-ticket/, AdminClientTicketTab, services/dto/i18n/docs associés - fixtures : retire les users client-liot / client-acme - migration Version20260522110000 (drop client_ticket, user_allowed_projects, colonnes liées ; task_document.task_id -> NOT NULL) - tests : retire les cas obsolètes testant le blocage des clients sur le mail Module gestion des absences (WIP) : - entités / migrations (Version20260521160000, Version20260522090000) - pages absences.vue / team-absences.vue, composants frontend/components/absence/ - services front, AccrueLeaveCommand, PublicHolidayController Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -14,10 +14,10 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||||
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||||
src/Enum/ # PHP enums (RecurrenceType)
|
src/Enum/ # PHP enums (RecurrenceType)
|
||||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||||
@@ -31,12 +31,12 @@ migrations/ # Migrations Doctrine
|
|||||||
docs/plans/ # Plans d'implémentation
|
docs/plans/ # Plans d'implémentation
|
||||||
docs/superpowers/ # Plans et specs superpowers
|
docs/superpowers/ # Plans et specs superpowers
|
||||||
frontend/ # App Nuxt 4
|
frontend/ # App Nuxt 4
|
||||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||||
frontend/layouts/ # Layouts (default, portal)
|
frontend/layouts/ # Layouts (default)
|
||||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
|
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab
|
||||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useAvatarService)
|
||||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
|
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, notifications, task-documents, zimbra, task-recurrences)
|
||||||
frontend/services/dto/ # Types TypeScript
|
frontend/services/dto/ # Types TypeScript
|
||||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||||
```
|
```
|
||||||
@@ -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`)
|
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
||||||
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
||||||
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
||||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml`
|
||||||
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
|
- `User::getRoles()` ajoute toujours `ROLE_USER`
|
||||||
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||||
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||||
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||||
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
||||||
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
- Endpoints ouverts à tout utilisateur authentifié : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -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/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
- MalioSelect : options `{ label: string, value: 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.
|
- 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
|
### 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)
|
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||||
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||||
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
|
||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
security:
|
security:
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
ROLE_ADMIN: [ROLE_USER]
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
@@ -64,7 +64,7 @@ security:
|
|||||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||||
# Mail : requiert authentification (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/mail, roles: IS_AUTHENTICATED_FULLY }
|
||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
|
|||||||
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`). ✅
|
||||||
@@ -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,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.
|
||||||
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>
|
||||||
163
frontend/components/absence/EmployeeDrawer.vue
Normal file
163
frontend/components/absence/EmployeeDrawer.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<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>
|
||||||
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>
|
<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">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
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>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
|||||||
@@ -119,20 +119,20 @@ async function handleSubmit(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="projectId">
|
<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>
|
||||||
|
|
||||||
<div v-if="projectId">
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
:options="projectFilterOptions"
|
:options="projectFilterOptions"
|
||||||
:label="t('mail.linkTaskModal.projectFilter')"
|
:label="t('mail.linkTaskModal.projectFilter')"
|
||||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Recherche tâche -->
|
<!-- Recherche tâche -->
|
||||||
|
|||||||
@@ -106,19 +106,7 @@ function handleClick(notif: Notification) {
|
|||||||
if (!notif.isRead) {
|
if (!notif.isRead) {
|
||||||
markAsRead(notif.id)
|
markAsRead(notif.id)
|
||||||
}
|
}
|
||||||
|
isOpen.value = false
|
||||||
if (notif.relatedTicket) {
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
|
||||||
|
|
||||||
if (isClient) {
|
|
||||||
navigateTo(`/portal`)
|
|
||||||
} else {
|
|
||||||
navigateTo(`/admin?tab=tickets`)
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkAllRead() {
|
async function handleMarkAllRead() {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="codeProxy"
|
v-model="codeProxy"
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
:options="clientOptions"
|
:options="clientOptions"
|
||||||
label="Client"
|
label="Client"
|
||||||
empty-option-label="Aucun client"
|
empty-option-label="Aucun client"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
:options="giteaRepoOptions"
|
:options="giteaRepoOptions"
|
||||||
label="Dépôt Gitea"
|
label="Dépôt Gitea"
|
||||||
empty-option-label="Aucun dépôt"
|
empty-option-label="Aucun dépôt"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
:options="bookstackShelfOptions"
|
:options="bookstackShelfOptions"
|
||||||
label="Étagère BookStack"
|
label="Étagère BookStack"
|
||||||
empty-option-label="Aucune étagère"
|
empty-option-label="Aucune étagère"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
:options="targetOptions"
|
:options="targetOptions"
|
||||||
:label="$t('workflows.switchTargetLabel')"
|
:label="$t('workflows.switchTargetLabel')"
|
||||||
empty-option-label="—"
|
empty-option-label="—"
|
||||||
min-width="!w-full"
|
group-class="!w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
empty-option-label="Status"
|
empty-option-label="Status"
|
||||||
min-width="!w-32"
|
group-class="!w-32"
|
||||||
text-field="text-xs"
|
text-field="text-xs"
|
||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="User"
|
empty-option-label="User"
|
||||||
min-width="!w-32"
|
group-class="!w-32"
|
||||||
text-field="text-xs"
|
text-field="text-xs"
|
||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
:options="priorityOptions"
|
:options="priorityOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
empty-option-label="Priorité"
|
empty-option-label="Priorité"
|
||||||
min-width="!w-32"
|
group-class="!w-32"
|
||||||
text-field="text-xs"
|
text-field="text-xs"
|
||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
:options="effortOptions"
|
:options="effortOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
empty-option-label="Effort"
|
empty-option-label="Effort"
|
||||||
min-width="!w-32"
|
group-class="!w-32"
|
||||||
text-field="text-xs"
|
text-field="text-xs"
|
||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Groupe"
|
empty-option-label="Groupe"
|
||||||
min-width="!w-32"
|
group-class="!w-32"
|
||||||
text-field="text-xs"
|
text-field="text-xs"
|
||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||||
|
|||||||
@@ -20,12 +20,6 @@
|
|||||||
name="mdi:flag-variant"
|
name="mdi:flag-variant"
|
||||||
class="h-3.5 w-3.5 text-red-600"
|
class="h-3.5 w-3.5 text-red-600"
|
||||||
/>
|
/>
|
||||||
<Icon
|
|
||||||
v-if="task.clientTicket"
|
|
||||||
name="heroicons:user-circle"
|
|
||||||
class="h-4 w-4 text-blue-400"
|
|
||||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,14 +50,13 @@ import { useTaskDocumentService } from '~/services/task-documents'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId?: number
|
taskId?: number
|
||||||
clientTicketId?: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploaded: []
|
uploaded: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
const { upload: uploadFile } = useTaskDocumentService()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -110,9 +109,7 @@ async function processFiles(files: File[]) {
|
|||||||
uploads.value.push(state)
|
uploads.value.push(state)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (props.clientTicketId) {
|
if (props.taskId) {
|
||||||
await uploadForTicket(props.clientTicketId, file)
|
|
||||||
} else if (props.taskId) {
|
|
||||||
await uploadFile(props.taskId, file)
|
await uploadFile(props.taskId, file)
|
||||||
}
|
}
|
||||||
state.uploading = false
|
state.uploading = false
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
v-model="branchForm.type"
|
v-model="branchForm.type"
|
||||||
:options="typeOptions"
|
:options="typeOptions"
|
||||||
:label="$t('gitea.branch.type')"
|
:label="$t('gitea.branch.type')"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="branchForm.baseBranch"
|
v-model="branchForm.baseBranch"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
|
|||||||
@@ -35,23 +35,6 @@
|
|||||||
@click="close"
|
@click="close"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client ticket link -->
|
|
||||||
<div
|
|
||||||
v-if="isEditing && task?.clientTicket"
|
|
||||||
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
|
||||||
<span class="text-sm font-medium text-blue-700">
|
|
||||||
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
|
||||||
:class="ticketStatusClass(task.clientTicket.status)"
|
|
||||||
>
|
|
||||||
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -91,7 +74,7 @@
|
|||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
label="Projet *"
|
label="Projet *"
|
||||||
empty-option-label="Sélectionner un projet"
|
empty-option-label="Sélectionner un projet"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||||
Le projet est requis
|
Le projet est requis
|
||||||
@@ -105,43 +88,35 @@
|
|||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
label="Statut"
|
label="Statut"
|
||||||
empty-option-label="Aucun statut"
|
empty-option-label="Aucun statut"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="form.assigneeId"
|
v-model="form.assigneeId"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="Aucun utilisateur"
|
empty-option-label="Aucun utilisateur"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="form.effortId"
|
v-model="form.effortId"
|
||||||
:options="effortOptions"
|
:options="effortOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
empty-option-label="Aucun effort"
|
empty-option-label="Aucun effort"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="form.priorityId"
|
v-model="form.priorityId"
|
||||||
:options="priorityOptions"
|
:options="priorityOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
empty-option-label="Aucune priorité"
|
empty-option-label="Aucune priorité"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="form.groupId"
|
v-model="form.groupId"
|
||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Aucun groupe"
|
empty-option-label="Aucun groupe"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-if="clientTicketOptions.length"
|
|
||||||
v-model="form.clientTicketId"
|
|
||||||
:options="clientTicketOptions"
|
|
||||||
label="Ticket client"
|
|
||||||
empty-option-label="Aucun ticket client"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -549,10 +524,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
|
||||||
import { useGiteaService } from '~/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import { useClientTicketService } from '~/services/client-tickets'
|
|
||||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
@@ -627,7 +600,6 @@ const form = reactive({
|
|||||||
collaboratorIds: [] as number[],
|
collaboratorIds: [] as number[],
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
clientTicketId: null as number | null,
|
|
||||||
projectId: null as number | null,
|
projectId: null as number | null,
|
||||||
scheduledStart: '',
|
scheduledStart: '',
|
||||||
scheduledEnd: '',
|
scheduledEnd: '',
|
||||||
@@ -757,7 +729,6 @@ function populateForm(task: Task | null) {
|
|||||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
form.clientTicketId = task.clientTicket?.id ?? null
|
|
||||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||||
@@ -804,7 +775,6 @@ function populateForm(task: Task | null) {
|
|||||||
form.collaboratorIds = []
|
form.collaboratorIds = []
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
form.clientTicketId = null
|
|
||||||
form.projectId = null
|
form.projectId = null
|
||||||
form.scheduledStart = ''
|
form.scheduledStart = ''
|
||||||
form.scheduledEnd = ''
|
form.scheduledEnd = ''
|
||||||
@@ -833,16 +803,6 @@ watch(() => props.modelValue, async (open) => {
|
|||||||
documentToDelete.value = null
|
documentToDelete.value = null
|
||||||
linkedMails.value = []
|
linkedMails.value = []
|
||||||
populateForm(props.task)
|
populateForm(props.task)
|
||||||
const pid = resolvedProjectId.value
|
|
||||||
if (pid) {
|
|
||||||
try {
|
|
||||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
|
||||||
} catch {
|
|
||||||
clientTickets.value = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientTickets.value = []
|
|
||||||
}
|
|
||||||
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||||
try {
|
try {
|
||||||
const settings = await getGiteaSettings()
|
const settings = await getGiteaSettings()
|
||||||
@@ -862,48 +822,26 @@ watch(() => props.task, (task) => {
|
|||||||
|
|
||||||
const { create, update, remove } = useTaskService()
|
const { create, update, remove } = useTaskService()
|
||||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||||
const clientTicketService = useClientTicketService()
|
|
||||||
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const clientTickets = ref<ClientTicket[]>([])
|
// Reset group when project changes in create mode
|
||||||
const clientTicketOptions = computed(() =>
|
watch(() => form.projectId, () => {
|
||||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reset group and reload client tickets when project changes in create mode
|
|
||||||
watch(() => form.projectId, async (pid) => {
|
|
||||||
if (!showProjectSelect.value) return
|
if (!showProjectSelect.value) return
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.clientTicketId = null
|
|
||||||
if (pid) {
|
|
||||||
try {
|
|
||||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
|
||||||
} catch {
|
|
||||||
clientTickets.value = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientTickets.value = []
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
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 availableTabs = computed(() => {
|
||||||
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||||
if (isEditing.value && isMailUser.value) base.push('mails')
|
if (isEditing.value) base.push('mails')
|
||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadLinkedMails(): Promise<void> {
|
async function loadLinkedMails(): Promise<void> {
|
||||||
if (!props.task || !isMailUser.value) return
|
if (!props.task) return
|
||||||
mailsLoading.value = true
|
mailsLoading.value = true
|
||||||
try {
|
try {
|
||||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
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 localDocuments = ref<TaskDocument[]>([])
|
||||||
const previewDoc = ref<TaskDocument | null>(null)
|
const previewDoc = ref<TaskDocument | null>(null)
|
||||||
|
|
||||||
@@ -1057,7 +985,6 @@ async function handleSubmit() {
|
|||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${resolvedProjectId.value}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
|
||||||
scheduledStart: form.scheduledStart || null,
|
scheduledStart: form.scheduledStart || null,
|
||||||
scheduledEnd: form.scheduledEnd || null,
|
scheduledEnd: form.scheduledEnd || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
v-model="form.userId"
|
v-model="form.userId"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
label="Utilisateur"
|
label="Utilisateur"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
label="Projet"
|
label="Projet"
|
||||||
empty-option-label="— Aucun —"
|
empty-option-label="— Aucun —"
|
||||||
min-width="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
v-for="day in days"
|
v-for="day in days"
|
||||||
:key="'header-' + day.dateStr"
|
:key="'header-' + day.dateStr"
|
||||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||||
|
:class="{ 'bg-orange-50': day.holiday }"
|
||||||
>
|
>
|
||||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||||
{{ day.dayNum }}
|
{{ day.dayNum }}
|
||||||
@@ -16,6 +17,14 @@
|
|||||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||||
{{ day.label }}
|
{{ day.label }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="day.holiday"
|
||||||
|
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
|
||||||
|
:title="day.holiday"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ day.holiday }}</span>
|
||||||
|
</div>
|
||||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +49,7 @@
|
|||||||
:key="day.dateStr"
|
:key="day.dateStr"
|
||||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||||
class="relative flex-1 border-r border-neutral-100"
|
class="relative flex-1 border-r border-neutral-100"
|
||||||
|
:class="{ 'bg-orange-50': day.holiday }"
|
||||||
@click="onClickGrid($event, day)"
|
@click="onClickGrid($event, day)"
|
||||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||||
>
|
>
|
||||||
@@ -141,8 +151,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const absenceService = useAbsenceService()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
@@ -209,6 +221,23 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Public holidays (computed server-side, shared with the absence calendar) ---
|
||||||
|
const holidays = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
async function loadHolidays() {
|
||||||
|
const count = props.viewMode === 'week' ? 7 : 1
|
||||||
|
const start = new Date(props.startDate)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(end.getDate() + count - 1)
|
||||||
|
try {
|
||||||
|
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
|
||||||
|
} catch {
|
||||||
|
holidays.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
|
||||||
|
|
||||||
// --- Days computation ---
|
// --- Days computation ---
|
||||||
const days = computed(() => {
|
const days = computed(() => {
|
||||||
const count = props.viewMode === 'week' ? 7 : 1
|
const count = props.viewMode === 'week' ? 7 : 1
|
||||||
@@ -231,6 +260,7 @@ const days = computed(() => {
|
|||||||
dateStr,
|
dateStr,
|
||||||
dayNum: d.getDate(),
|
dayNum: d.getDate(),
|
||||||
label: dayLabels[d.getDay()],
|
label: dayLabels[d.getDay()],
|
||||||
|
holiday: holidays.value[dateStr] ?? null,
|
||||||
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
|
<MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
|
||||||
|
</template>
|
||||||
<div class="flex flex-col gap-6 p-4">
|
<div class="flex flex-col gap-6 p-4">
|
||||||
<!-- Period presets -->
|
<!-- Period presets -->
|
||||||
<div>
|
<div>
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
:label="$t('timeEntries.exportUsers')"
|
:label="$t('timeEntries.exportUsers')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:display-select-all="true"
|
:display-select-all="true"
|
||||||
min-width="!w-full"
|
group-class="!w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
:options="clientOptions"
|
:options="clientOptions"
|
||||||
:label="$t('timeEntries.exportClient')"
|
:label="$t('timeEntries.exportClient')"
|
||||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||||
min-width="!w-full"
|
group-class="!w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@
|
|||||||
:label="$t('timeEntries.exportProjects')"
|
:label="$t('timeEntries.exportProjects')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:display-select-all="true"
|
:display-select-all="true"
|
||||||
min-width="!w-full"
|
group-class="!w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +90,7 @@
|
|||||||
:label="$t('timeEntries.exportTags')"
|
:label="$t('timeEntries.exportTags')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:display-select-all="true"
|
:display-select-all="true"
|
||||||
min-width="!w-full"
|
group-class="!w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">{{ isEditing ? $t('users.editUser') : $t('users.addUser') }}</h2>
|
||||||
|
</template>
|
||||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -35,37 +38,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<!-- RH / Absences -->
|
||||||
<MalioSelect
|
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||||
v-model="form.clientId"
|
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||||
label="Client"
|
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
|
||||||
:options="clientOptions"
|
Les informations RH (contrat, dates, CP…) se gèrent dans Absences équipe → onglet Employés.
|
||||||
placeholder="Aucun client"
|
</p>
|
||||||
class="w-full"
|
|
||||||
@update:model-value="onClientChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.clientId !== null" class="mt-2">
|
|
||||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
|
||||||
<div class="mt-2 flex flex-col gap-2">
|
|
||||||
<label
|
|
||||||
v-for="project in filteredProjects"
|
|
||||||
:key="project.id"
|
|
||||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.allowedProjectIds"
|
|
||||||
type="checkbox"
|
|
||||||
:value="project.id"
|
|
||||||
class="rounded border-neutral-300"
|
|
||||||
/>
|
|
||||||
{{ project.name }}
|
|
||||||
</label>
|
|
||||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
|
||||||
Aucun projet pour ce client.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
@@ -83,12 +61,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
import { useClientService } from '~/services/clients'
|
|
||||||
import { useProjectService } from '~/services/projects'
|
|
||||||
import type { Client } from '~/services/dto/client'
|
|
||||||
import type { Project } from '~/services/dto/project'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -105,32 +77,16 @@ const isOpen = computed({
|
|||||||
set: (v) => emit('update:modelValue', v),
|
set: (v) => emit('update:modelValue', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.item)
|
const isEditing = computed(() => !!props.item)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
const clients = ref<Client[]>([])
|
|
||||||
const allProjects = ref<Project[]>([])
|
|
||||||
|
|
||||||
const clientOptions = computed(() => [
|
|
||||||
{ label: t('common.noClient'), value: null as number | null },
|
|
||||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
|
||||||
])
|
|
||||||
|
|
||||||
const filteredProjects = computed(() => {
|
|
||||||
if (form.clientId === null) return []
|
|
||||||
return allProjects.value.filter(
|
|
||||||
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
clientId: null as number | null,
|
isEmployee: false,
|
||||||
allowedProjectIds: [] as number[],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
@@ -138,45 +94,21 @@ const touched = reactive({
|
|||||||
password: false,
|
password: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
function onClientChange(value: number | null) {
|
watch(() => props.modelValue, (open) => {
|
||||||
form.clientId = value
|
|
||||||
form.allowedProjectIds = []
|
|
||||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
|
||||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => form.roles, (roles) => {
|
|
||||||
if (!roles.includes('ROLE_CLIENT')) {
|
|
||||||
form.clientId = null
|
|
||||||
form.allowedProjectIds = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.modelValue, async (open) => {
|
|
||||||
if (open) {
|
if (open) {
|
||||||
if (props.item) {
|
if (props.item) {
|
||||||
form.username = props.item.username ?? ''
|
form.username = props.item.username ?? ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.roles = [...props.item.roles]
|
form.roles = [...props.item.roles]
|
||||||
form.clientId = props.item.client?.id ?? null
|
form.isEmployee = props.item.isEmployee ?? false
|
||||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
|
||||||
} else {
|
} else {
|
||||||
form.username = ''
|
form.username = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.roles = ['ROLE_USER']
|
form.roles = ['ROLE_USER']
|
||||||
form.clientId = null
|
form.isEmployee = false
|
||||||
form.allowedProjectIds = []
|
|
||||||
}
|
}
|
||||||
touched.username = false
|
touched.username = false
|
||||||
touched.password = false
|
touched.password = false
|
||||||
|
|
||||||
const [loadedClients, loadedProjects] = await Promise.all([
|
|
||||||
useClientService().getAll(),
|
|
||||||
useProjectService().getAll({ archived: false }),
|
|
||||||
])
|
|
||||||
clients.value = loadedClients
|
|
||||||
allProjects.value = loadedProjects
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,10 +125,7 @@ async function handleSubmit() {
|
|||||||
const payload: UserWrite = {
|
const payload: UserWrite = {
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
roles: form.roles,
|
roles: form.roles,
|
||||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
isEmployee: form.isEmployee,
|
||||||
allowedProjects: form.clientId !== null
|
|
||||||
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
|
|
||||||
: [],
|
|
||||||
}
|
}
|
||||||
if (form.password) {
|
if (form.password) {
|
||||||
payload.plainPassword = form.password
|
payload.plainPassword = form.password
|
||||||
|
|||||||
93
frontend/composables/useAbsenceHelpers.ts
Normal file
93
frontend/composables/useAbsenceHelpers.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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',
|
||||||
|
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
|
# 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)
|
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||||
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||||
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
- ⏱️ **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
|
## 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 |
|
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
|
||||||
|
|
||||||
## Vues principales
|
## 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é
|
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||||
- **Time tracking** : timer, time entries, vue mois
|
- **Time tracking** : timer, time entries, vue mois
|
||||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
- **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.
|
> 💡 **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`)
|
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||||
- **Voir les PRs** liées (état CI inclus)
|
- **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
|
## Commentaires & notifications
|
||||||
|
|
||||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||||
|
|||||||
@@ -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*
|
## Onglet *Utilisateurs*
|
||||||
|
|
||||||
- Créer / éditer / désactiver
|
- Créer / éditer / désactiver
|
||||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`
|
||||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
|
||||||
- Reset password depuis l'admin
|
- 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*
|
## Onglet *Gitea*
|
||||||
|
|
||||||
- URL serveur + token API
|
- URL serveur + token API
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directemen
|
|||||||
|
|
||||||
> 📥 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.
|
> 📥 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**. Les utilisateurs *client* sont redirigés vers leur portail.
|
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**.
|
||||||
|
|
||||||
## L'interface
|
## L'interface
|
||||||
|
|
||||||
|
|||||||
@@ -351,63 +351,6 @@
|
|||||||
"error": "Erreur de connexion à Gitea.",
|
"error": "Erreur de connexion à Gitea.",
|
||||||
"notConfigured": "Gitea non configuré pour ce projet."
|
"notConfigured": "Gitea non configuré pour ce projet."
|
||||||
},
|
},
|
||||||
"portal": {
|
|
||||||
"title": "Portail client",
|
|
||||||
"projects": "Vos projets",
|
|
||||||
"noProjects": "Aucun projet disponible.",
|
|
||||||
"openTickets": "tickets ouverts",
|
|
||||||
"newTicket": "Nouveau ticket",
|
|
||||||
"ticketDetail": "Détail du ticket",
|
|
||||||
"backToProject": "Retour au projet",
|
|
||||||
"submitTicket": "Soumettre le ticket",
|
|
||||||
"ticketCreated": "Ticket soumis avec succès."
|
|
||||||
},
|
|
||||||
"clientTicket": {
|
|
||||||
"title": "Tickets",
|
|
||||||
"new": "Nouveau ticket",
|
|
||||||
"created": "Ticket créé avec succès.",
|
|
||||||
"deleted": "Ticket supprimé avec succès.",
|
|
||||||
"updated": "Ticket mis à jour avec succès.",
|
|
||||||
"statusUpdated": "Statut du ticket mis à jour.",
|
|
||||||
"type": {
|
|
||||||
"bug": "Bug",
|
|
||||||
"improvement": "Amélioration",
|
|
||||||
"other": "Autre"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"new": "Nouveau",
|
|
||||||
"in_progress": "En cours",
|
|
||||||
"done": "Terminé",
|
|
||||||
"rejected": "Rejeté"
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"title": "Titre",
|
|
||||||
"description": "Description",
|
|
||||||
"url": "URL de la page",
|
|
||||||
"urlPlaceholder": "https://example.com/page-concernee",
|
|
||||||
"type": "Type",
|
|
||||||
"project": "Projet"
|
|
||||||
},
|
|
||||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
|
|
||||||
"rejectComment": "Commentaire de rejet",
|
|
||||||
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
|
|
||||||
"linkedTicket": "Lié au ticket client CT-{number}",
|
|
||||||
"description": "Description",
|
|
||||||
"url": "URL (page concernée)",
|
|
||||||
"statusComment": "Commentaire de statut",
|
|
||||||
"statusChanged": "Statut mis à jour",
|
|
||||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
|
||||||
"linkedTooltip": "Lié au ticket client {number}",
|
|
||||||
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
|
||||||
"noTickets": "Aucun ticket.",
|
|
||||||
"allStatuses": "Tous les statuts",
|
|
||||||
"allProjects": "Tous les projets",
|
|
||||||
"submittedBy": "Soumis par",
|
|
||||||
"createdAt": "Créé le",
|
|
||||||
"adminTab": "Tickets client",
|
|
||||||
"selectType": "Type de ticket",
|
|
||||||
"changeStatus": "Changer le statut"
|
|
||||||
},
|
|
||||||
"notification": {
|
"notification": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"markAllRead": "Tout marquer comme lu",
|
"markAllRead": "Tout marquer comme lu",
|
||||||
@@ -614,5 +557,195 @@
|
|||||||
"hasAttachments": "Pièces jointes",
|
"hasAttachments": "Pièces jointes",
|
||||||
"unread": "non lu | non lus",
|
"unread": "non lu | non lus",
|
||||||
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
"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",
|
||||||
|
"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",
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {
|
||||||
|
"title": "Politiques d'absence",
|
||||||
|
"subtitle": "Réglez les défauts par type d'absence (convention collective).",
|
||||||
|
"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'"
|
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||||
@click="ui.closeMobileSidebar()"
|
@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
|
<SidebarLink
|
||||||
to="/my-tasks"
|
to="/my-tasks"
|
||||||
icon="mdi:clipboard-check-outline"
|
icon="mdi:clipboard-check-outline"
|
||||||
@@ -53,23 +59,6 @@
|
|||||||
:collapsed="sidebarIsCollapsed"
|
:collapsed="sidebarIsCollapsed"
|
||||||
@click="ui.closeMobileSidebar()"
|
@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
|
<SidebarLink
|
||||||
to="/projects"
|
to="/projects"
|
||||||
icon="mdi:folder-outline"
|
icon="mdi:folder-outline"
|
||||||
@@ -103,14 +92,6 @@
|
|||||||
sub
|
sub
|
||||||
@click="ui.closeMobileSidebar()"
|
@click="ui.closeMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
<SidebarLink
|
|
||||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
|
||||||
icon="mdi:ticket-outline"
|
|
||||||
label="Tickets client"
|
|
||||||
:collapsed="sidebarIsCollapsed"
|
|
||||||
sub
|
|
||||||
@click="ui.closeMobileSidebar()"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/time-tracking"
|
to="/time-tracking"
|
||||||
@@ -119,13 +100,59 @@
|
|||||||
:collapsed="sidebarIsCollapsed"
|
:collapsed="sidebarIsCollapsed"
|
||||||
@click="ui.closeMobileSidebar()"
|
@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
|
<SidebarLink
|
||||||
to="/admin"
|
to="/absences"
|
||||||
icon="mdi:cog-outline"
|
icon="mdi:umbrella-beach-outline"
|
||||||
label="Administration"
|
label="Mes absences"
|
||||||
:collapsed="sidebarIsCollapsed"
|
:collapsed="sidebarIsCollapsed"
|
||||||
@click="ui.closeMobileSidebar()"
|
@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>
|
</nav>
|
||||||
|
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
@@ -183,12 +210,11 @@ const mailStore = useMailStore()
|
|||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
|
||||||
|
|
||||||
const isMailVisible = computed(() => {
|
const isMailVisible = computed(() => {
|
||||||
const roles: string[] = auth.user?.roles ?? []
|
const roles: string[] = auth.user?.roles ?? []
|
||||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||||
&& !roles.includes('ROLE_ADMIN')
|
|
||||||
&& !roles.includes('ROLE_USER')
|
|
||||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
// 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')
|
return navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isClientOnly = auth.isAuthenticated
|
|
||||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
|
||||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
|
||||||
|
|
||||||
if (isLogin && auth.isAuthenticated) {
|
if (isLogin && auth.isAuthenticated) {
|
||||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
return navigateTo('/')
|
||||||
}
|
|
||||||
|
|
||||||
const isProfileRoute = to.path === '/profile'
|
|
||||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
|
||||||
return navigateTo('/portal')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
|||||||
"name": "nuxt-app",
|
"name": "nuxt-app",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.4.8",
|
"@malio/layer-ui": "^1.6.0",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -2210,9 +2210,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.4.8",
|
"version": "1.6.0",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.8/layer-ui-1.4.8.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||||
"integrity": "sha512-ABQmfMqJqKGGnx6kf5KK/XVuKAPWSpRHmLpS9XMg6pUH8kww8o3JoywlrlFkk9xA30zNFaehAtzV7S19E4JTlg==",
|
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.4.8",
|
"@malio/layer-ui": "^1.6.0",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@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'" />
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||||
|
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,6 +51,7 @@ const tabs = [
|
|||||||
{ key: 'bookstack', label: 'BookStack' },
|
{ key: 'bookstack', label: 'BookStack' },
|
||||||
{ key: 'zimbra', label: 'Zimbra' },
|
{ key: 'zimbra', label: 'Zimbra' },
|
||||||
{ key: 'mail', label: 'Mail' },
|
{ key: 'mail', label: 'Mail' },
|
||||||
|
{ key: 'absences', label: 'Absences' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TabKey = typeof tabs[number]['key']
|
type TabKey = typeof tabs[number]['key']
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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'] },
|
'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'] },
|
'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'] },
|
'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'] },
|
|
||||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
'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'] },
|
'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'] },
|
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||||
@@ -40,7 +39,6 @@ const auth = useAuthStore()
|
|||||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||||
const roles = auth.user?.roles ?? []
|
const roles = auth.user?.roles ?? []
|
||||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
|
||||||
return 'user'
|
return 'user'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ const lineOptions = {
|
|||||||
v-model="selectedPeriod"
|
v-model="selectedPeriod"
|
||||||
:options="periodOptions"
|
:options="periodOptions"
|
||||||
:label="$t('dashboard.filters.period')"
|
:label="$t('dashboard.filters.period')"
|
||||||
min-width="!w-48"
|
group-class="!w-48"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -524,7 +524,7 @@ const lineOptions = {
|
|||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
:label="$t('dashboard.filters.project')"
|
:label="$t('dashboard.filters.project')"
|
||||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -533,7 +533,7 @@ const lineOptions = {
|
|||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
:label="$t('dashboard.filters.user')"
|
:label="$t('dashboard.filters.user')"
|
||||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ async function handleSubmit() {
|
|||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
await navigateTo('/')
|
||||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
|
||||||
await navigateTo(isClient ? '/portal' : '/')
|
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useMailStore } from '~/stores/mail'
|
import { useMailStore } from '~/stores/mail'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
useHead({ title: t('mail.title') })
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Store ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const store = useMailStore()
|
const store = useMailStore()
|
||||||
@@ -40,11 +25,6 @@ const {
|
|||||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (isClientOnly.value) {
|
|
||||||
router.replace('/portal')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (folderTree.value.length === 0) {
|
if (folderTree.value.length === 0) {
|
||||||
await store.fetchFolders()
|
await store.fetchFolders()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ onMounted(async () => {
|
|||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
label="Projet"
|
label="Projet"
|
||||||
:empty-option-label="$t('myTasks.allProjects')"
|
:empty-option-label="$t('myTasks.allProjects')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -381,7 +381,7 @@ onMounted(async () => {
|
|||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
:empty-option-label="$t('myTasks.allGroups')"
|
:empty-option-label="$t('myTasks.allGroups')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -390,7 +390,7 @@ onMounted(async () => {
|
|||||||
:options="tagOptions"
|
:options="tagOptions"
|
||||||
label="Type"
|
label="Type"
|
||||||
:empty-option-label="$t('myTasks.allTypes')"
|
:empty-option-label="$t('myTasks.allTypes')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -399,7 +399,7 @@ onMounted(async () => {
|
|||||||
:options="priorityOptions"
|
:options="priorityOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
:empty-option-label="$t('myTasks.allPriorities')"
|
:empty-option-label="$t('myTasks.allPriorities')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -408,7 +408,7 @@ onMounted(async () => {
|
|||||||
:options="effortOptions"
|
:options="effortOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
:empty-option-label="$t('myTasks.allEfforts')"
|
:empty-option-label="$t('myTasks.allEfforts')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +417,7 @@ onMounted(async () => {
|
|||||||
:options="assigneeOptions"
|
:options="assigneeOptions"
|
||||||
label="Assigné"
|
label="Assigné"
|
||||||
:empty-option-label="$t('myTasks.allAssignees')"
|
:empty-option-label="$t('myTasks.allAssignees')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -426,7 +426,7 @@ onMounted(async () => {
|
|||||||
:options="sortOptions"
|
:options="sortOptions"
|
||||||
:label="$t('myTasks.sortBy')"
|
:label="$t('myTasks.sortBy')"
|
||||||
:empty-option-label="$t('myTasks.sortDefault')"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="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>
|
<template>
|
||||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
<NuxtLayout name="default">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<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>
|
<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>
|
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<label
|
<MalioButton
|
||||||
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
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
|
<MalioButton
|
||||||
v-if="auth.user?.avatarUrl"
|
v-if="auth.user?.avatarUrl"
|
||||||
@@ -39,7 +40,6 @@
|
|||||||
|
|
||||||
<!-- API Token MCP (interne uniquement) -->
|
<!-- API Token MCP (interne uniquement) -->
|
||||||
<div
|
<div
|
||||||
v-if="!isClientOnly"
|
|
||||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
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>
|
<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 toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isClientOnly = computed(() =>
|
|
||||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
|
||||||
)
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
@@ -145,6 +141,7 @@ const { upload, remove } = useAvatarService()
|
|||||||
const { regenerate } = useApiTokenService()
|
const { regenerate } = useApiTokenService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||||
const removing = ref(false)
|
const removing = ref(false)
|
||||||
const regenerating = ref(false)
|
const regenerating = ref(false)
|
||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
:options="groupFilterOptions"
|
:options="groupFilterOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Tous les groupes"
|
empty-option-label="Tous les groupes"
|
||||||
min-width="w-64"
|
group-class="w-64"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
:options="groupFilterOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Tous les groupes"
|
empty-option-label="Tous les groupes"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
:options="tagFilterOptions"
|
:options="tagFilterOptions"
|
||||||
label="Tags"
|
label="Tags"
|
||||||
empty-option-label="Tous les tags"
|
empty-option-label="Tous les tags"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
:options="userFilterOptions"
|
:options="userFilterOptions"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="Tous les users"
|
empty-option-label="Tous les users"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
empty-option-label="Tous les status"
|
empty-option-label="Tous les status"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
:options="priorityFilterOptions"
|
:options="priorityFilterOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
empty-option-label="Toutes"
|
empty-option-label="Toutes"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
:options="effortFilterOptions"
|
:options="effortFilterOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
min-width="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="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
|
<MalioSelect
|
||||||
v-model="selectedUserId"
|
v-model="selectedUserId"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
min-width="!w-36 sm:!w-44"
|
group-class="!w-36 sm:!w-44"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
label="User"
|
label="User"
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
label="Projet"
|
label="Projet"
|
||||||
min-width="!w-36 sm:!w-44"
|
group-class="!w-36 sm:!w-44"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
:options="tagOptions"
|
:options="tagOptions"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
label="Tag"
|
label="Tag"
|
||||||
min-width="!w-36 sm:!w-44"
|
group-class="!w-36 sm:!w-44"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="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' | '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
|
type: NotificationType
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
relatedTicket: string | null
|
|
||||||
isRead: boolean
|
isRead: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ export type Task = {
|
|||||||
tags: TaskTag[]
|
tags: TaskTag[]
|
||||||
documents: TaskDocument[]
|
documents: TaskDocument[]
|
||||||
archived: boolean
|
archived: boolean
|
||||||
clientTicket: {
|
|
||||||
id: number
|
|
||||||
number: number
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
title: string
|
|
||||||
} | null
|
|
||||||
scheduledStart: string | null
|
scheduledStart: string | null
|
||||||
scheduledEnd: string | null
|
scheduledEnd: string | null
|
||||||
deadline: string | null
|
deadline: string | null
|
||||||
@@ -61,7 +54,6 @@ export type TaskWrite = {
|
|||||||
project: string
|
project: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
clientTicket?: string | null
|
|
||||||
scheduledStart?: string | null
|
scheduledStart?: string | null
|
||||||
scheduledEnd?: string | null
|
scheduledEnd?: string | null
|
||||||
deadline?: string | null
|
deadline?: string | null
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import type { Project } from './project'
|
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
||||||
|
export type FamilySituation = 'CELIBATAIRE' | 'MARIE' | 'PACSE' | 'DIVORCE' | 'VEUF'
|
||||||
|
|
||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
username: string
|
username: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
client?: { id: number; name: string } | null
|
|
||||||
allowedProjects?: Project[]
|
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: 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
|
||||||
|
familySituation?: FamilySituation | null
|
||||||
|
nbChildren?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWrite = {
|
export type UserWrite = {
|
||||||
username: string
|
username: string
|
||||||
plainPassword?: string
|
plainPassword?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
client?: string | null
|
// HR / absence management
|
||||||
allowedProjects?: string[]
|
isEmployee?: boolean
|
||||||
|
hireDate?: string | null
|
||||||
|
endDate?: string | null
|
||||||
|
contractType?: ContractType | null
|
||||||
|
workTimeRatio?: number
|
||||||
|
annualLeaveDays?: number
|
||||||
|
referencePeriodStart?: string
|
||||||
|
initialLeaveBalance?: number
|
||||||
|
familySituation?: FamilySituation | null
|
||||||
|
nbChildren?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,6 @@ export function useTaskDocumentService() {
|
|||||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
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> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/task_documents/${id}`, {}, {
|
await api.delete(`/task_documents/${id}`, {}, {
|
||||||
toastSuccessKey: 'taskDocuments.deleted',
|
toastSuccessKey: 'taskDocuments.deleted',
|
||||||
@@ -52,5 +41,5 @@ export function useTaskDocumentService() {
|
|||||||
return `${baseURL}/task_documents/${id}/download`
|
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:
|
build-nuxtJS:
|
||||||
# $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local
|
# $(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"
|
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
|
||||||
|
|
||||||
dev-nuxt:
|
dev-nuxt:
|
||||||
|
|||||||
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Controller/Absence/PublicHolidayController.php
Normal file
46
src/Controller/Absence/PublicHolidayController.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Absence;
|
||||||
|
|
||||||
|
use App\Service\PublicHolidayProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes French public holidays so the front (calendar, date pickers) can
|
||||||
|
* display them — the dates are computed server-side in pure PHP.
|
||||||
|
*/
|
||||||
|
class PublicHolidayController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PublicHolidayProvider $holidayProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/public_holidays', name: 'public_holidays', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$fromRaw = (string) $request->query->get('from', '');
|
||||||
|
$toRaw = (string) $request->query->get('to', '');
|
||||||
|
|
||||||
|
if ('' !== $fromRaw && '' !== $toRaw) {
|
||||||
|
$fromYear = (int) new DateTimeImmutable($fromRaw)->format('Y');
|
||||||
|
$toYear = (int) new DateTimeImmutable($toRaw)->format('Y');
|
||||||
|
} else {
|
||||||
|
$fromYear = $toYear = (int) ($request->query->get('year') ?: date('Y'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$holidays = [];
|
||||||
|
for ($year = $fromYear; $year <= $toYear; ++$year) {
|
||||||
|
$holidays += $this->holidayProvider->getHolidays($year);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($holidays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,8 @@ namespace App\Controller;
|
|||||||
use App\Entity\TaskDocument;
|
use App\Entity\TaskDocument;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -19,7 +17,6 @@ class TaskDocumentDownloadController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
|
||||||
private readonly string $uploadDir,
|
private readonly string $uploadDir,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -33,14 +30,6 @@ class TaskDocumentDownloadController extends AbstractController
|
|||||||
throw new NotFoundHttpException('Document not found.');
|
throw new NotFoundHttpException('Document not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ROLE_CLIENT can only download documents from their own tickets
|
|
||||||
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_USER')) {
|
|
||||||
$ticket = $document->getClientTicket();
|
|
||||||
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
|
|
||||||
throw new AccessDeniedHttpException('You do not have access to this document.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||||
|
|
||||||
if (!file_exists($filePath)) {
|
if (!file_exists($filePath)) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Entity\AbsenceBalance;
|
|||||||
use App\Entity\AbsencePolicy;
|
use App\Entity\AbsencePolicy;
|
||||||
use App\Entity\AbsenceRequest;
|
use App\Entity\AbsenceRequest;
|
||||||
use App\Entity\Client;
|
use App\Entity\Client;
|
||||||
use App\Entity\ClientTicket;
|
|
||||||
use App\Entity\MailConfiguration;
|
use App\Entity\MailConfiguration;
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
@@ -593,94 +592,6 @@ class AppFixtures extends Fixture
|
|||||||
$manager->persist($entry);
|
$manager->persist($entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Client Users
|
|
||||||
// =============================================
|
|
||||||
$clientUserLiot = new User();
|
|
||||||
$clientUserLiot->setUsername('client-liot');
|
|
||||||
$clientUserLiot->setRoles(['ROLE_CLIENT']);
|
|
||||||
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client'));
|
|
||||||
$clientUserLiot->setClient($clientLiot);
|
|
||||||
$clientUserLiot->addAllowedProject($projectSirh);
|
|
||||||
$manager->persist($clientUserLiot);
|
|
||||||
|
|
||||||
$clientUserAcme = new User();
|
|
||||||
$clientUserAcme->setUsername('client-acme');
|
|
||||||
$clientUserAcme->setRoles(['ROLE_CLIENT']);
|
|
||||||
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client'));
|
|
||||||
$clientUserAcme->setClient($clientAcme);
|
|
||||||
$clientUserAcme->addAllowedProject($projectCrm);
|
|
||||||
$manager->persist($clientUserAcme);
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Client Tickets
|
|
||||||
// =============================================
|
|
||||||
$ticket1 = new ClientTicket();
|
|
||||||
$ticket1->setNumber(1);
|
|
||||||
$ticket1->setType('bug');
|
|
||||||
$ticket1->setTitle('Erreur 500 sur la page de login');
|
|
||||||
$ticket1->setDescription('Quand je clique sur "Se connecter" avec un mot de passe vide, j\'obtiens une page blanche avec une erreur 500.');
|
|
||||||
$ticket1->setUrl('https://sirh.liot.fr/login');
|
|
||||||
$ticket1->setStatus('new');
|
|
||||||
$ticket1->setProject($projectSirh);
|
|
||||||
$ticket1->setSubmittedBy($clientUserLiot);
|
|
||||||
$ticket1->setCreatedAt(new DateTimeImmutable('-3 days'));
|
|
||||||
$ticket1->setUpdatedAt(new DateTimeImmutable('-3 days'));
|
|
||||||
$manager->persist($ticket1);
|
|
||||||
|
|
||||||
$ticket2 = new ClientTicket();
|
|
||||||
$ticket2->setNumber(2);
|
|
||||||
$ticket2->setType('improvement');
|
|
||||||
$ticket2->setTitle('Ajouter un export PDF des fiches employés');
|
|
||||||
$ticket2->setDescription('Il serait utile de pouvoir exporter les fiches employés au format PDF pour les archiver.');
|
|
||||||
$ticket2->setStatus('in_progress');
|
|
||||||
$ticket2->setProject($projectSirh);
|
|
||||||
$ticket2->setSubmittedBy($clientUserLiot);
|
|
||||||
$ticket2->setCreatedAt(new DateTimeImmutable('-7 days'));
|
|
||||||
$ticket2->setUpdatedAt(new DateTimeImmutable('-2 days'));
|
|
||||||
$manager->persist($ticket2);
|
|
||||||
|
|
||||||
$ticket3 = new ClientTicket();
|
|
||||||
$ticket3->setNumber(3);
|
|
||||||
$ticket3->setType('other');
|
|
||||||
$ticket3->setTitle('Demande de formation sur le module congés');
|
|
||||||
$ticket3->setDescription('Notre équipe RH souhaiterait une formation sur le nouveau module de gestion des congés.');
|
|
||||||
$ticket3->setStatus('done');
|
|
||||||
$ticket3->setStatusComment('Formation planifiée le 20/03. Ticket clos.');
|
|
||||||
$ticket3->setProject($projectSirh);
|
|
||||||
$ticket3->setSubmittedBy($clientUserLiot);
|
|
||||||
$ticket3->setCreatedAt(new DateTimeImmutable('-14 days'));
|
|
||||||
$ticket3->setUpdatedAt(new DateTimeImmutable('-5 days'));
|
|
||||||
$manager->persist($ticket3);
|
|
||||||
|
|
||||||
$ticket4 = new ClientTicket();
|
|
||||||
$ticket4->setNumber(1);
|
|
||||||
$ticket4->setType('bug');
|
|
||||||
$ticket4->setTitle('Doublons dans la liste des contacts');
|
|
||||||
$ticket4->setDescription('Certains contacts apparaissent en double après l\'import CSV. Le problème semble lié aux accents dans les noms.');
|
|
||||||
$ticket4->setStatus('new');
|
|
||||||
$ticket4->setProject($projectCrm);
|
|
||||||
$ticket4->setSubmittedBy($clientUserAcme);
|
|
||||||
$ticket4->setCreatedAt(new DateTimeImmutable('-1 day'));
|
|
||||||
$ticket4->setUpdatedAt(new DateTimeImmutable('-1 day'));
|
|
||||||
$manager->persist($ticket4);
|
|
||||||
|
|
||||||
$ticket5 = new ClientTicket();
|
|
||||||
$ticket5->setNumber(2);
|
|
||||||
$ticket5->setType('improvement');
|
|
||||||
$ticket5->setTitle('Filtre par date sur le pipeline de vente');
|
|
||||||
$ticket5->setDescription('Pouvoir filtrer le pipeline de vente par période (mois, trimestre, année).');
|
|
||||||
$ticket5->setStatus('rejected');
|
|
||||||
$ticket5->setStatusComment('Cette fonctionnalité est déjà prévue dans la prochaine version. Pas besoin de ticket spécifique.');
|
|
||||||
$ticket5->setProject($projectCrm);
|
|
||||||
$ticket5->setSubmittedBy($clientUserAcme);
|
|
||||||
$ticket5->setCreatedAt(new DateTimeImmutable('-10 days'));
|
|
||||||
$ticket5->setUpdatedAt(new DateTimeImmutable('-8 days'));
|
|
||||||
$manager->persist($ticket5);
|
|
||||||
|
|
||||||
// Link a task to a client ticket
|
|
||||||
$task3->setClientTicket($ticket1);
|
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Zimbra Configuration
|
// Zimbra Configuration
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -777,18 +688,19 @@ class AppFixtures extends Fixture
|
|||||||
// Paid-leave balances for the current reference period (June 1st → May 31st)
|
// Paid-leave balances for the current reference period (June 1st → May 31st)
|
||||||
$cpPeriod = '2025-2026';
|
$cpPeriod = '2025-2026';
|
||||||
$balanceData = [
|
$balanceData = [
|
||||||
// [user, acquired, taken, pending]
|
// [user, acquired (N-1), acquiring (N, en cours), taken, pending]
|
||||||
[$admin, 22.5, 5.0, 0.0],
|
[$admin, 10.0, 22.5, 5.0, 0.0],
|
||||||
[$userAlice, 18.0, 2.0, 5.0],
|
[$userAlice, 8.0, 18.0, 2.0, 5.0],
|
||||||
[$userBob, 14.0, 0.0, 0.0],
|
[$userBob, 0.0, 14.0, 0.0, 0.0],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($balanceData as [$bUser, $acquired, $taken, $pending]) {
|
foreach ($balanceData as [$bUser, $acquired, $acquiring, $taken, $pending]) {
|
||||||
$balance = new AbsenceBalance();
|
$balance = new AbsenceBalance();
|
||||||
$balance->setUser($bUser);
|
$balance->setUser($bUser);
|
||||||
$balance->setType(AbsenceType::PaidLeave);
|
$balance->setType(AbsenceType::PaidLeave);
|
||||||
$balance->setPeriod($cpPeriod);
|
$balance->setPeriod($cpPeriod);
|
||||||
$balance->setAcquired($acquired);
|
$balance->setAcquired($acquired);
|
||||||
|
$balance->setAcquiring($acquiring);
|
||||||
$balance->setTaken($taken);
|
$balance->setTaken($taken);
|
||||||
$balance->setPending($pending);
|
$balance->setPending($pending);
|
||||||
$manager->persist($balance);
|
$manager->persist($balance);
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Doctrine;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use App\Entity\Project;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
|
|
||||||
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
|
||||||
{
|
|
||||||
$this->addWhere($queryBuilder, $resourceClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
|
|
||||||
{
|
|
||||||
$this->addWhere($queryBuilder, $resourceClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
|
|
||||||
{
|
|
||||||
if (Project::class !== $resourceClass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only restrict for ROLE_CLIENT users who are NOT admins
|
|
||||||
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
|
||||||
|
|
||||||
$allowedProjectIds = $user->getAllowedProjects()->map(
|
|
||||||
fn (Project $project) => $project->getId(),
|
|
||||||
)->toArray();
|
|
||||||
|
|
||||||
if ([] === $allowedProjectIds) {
|
|
||||||
$queryBuilder->andWhere('1 = 0');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$queryBuilder
|
|
||||||
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
|
|
||||||
->setParameter('allowed_project_ids', $allowedProjectIds)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,10 +59,16 @@ class AbsenceBalance
|
|||||||
#[Groups(['absence_balance:read'])]
|
#[Groups(['absence_balance:read'])]
|
||||||
private ?string $period = null;
|
private ?string $period = null;
|
||||||
|
|
||||||
|
/** Days acquired during the *previous* reference period (Congés N-1): fully available to take. */
|
||||||
#[ORM\Column(type: Types::FLOAT)]
|
#[ORM\Column(type: Types::FLOAT)]
|
||||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||||
private float $acquired = 0.0;
|
private float $acquired = 0.0;
|
||||||
|
|
||||||
|
/** Days being accrued during the *current* reference period (Congés N): "en cours d'acquisition". */
|
||||||
|
#[ORM\Column(type: Types::FLOAT)]
|
||||||
|
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||||
|
private float $acquiring = 0.0;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::FLOAT)]
|
#[ORM\Column(type: Types::FLOAT)]
|
||||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||||
private float $taken = 0.0;
|
private float $taken = 0.0;
|
||||||
@@ -72,10 +78,25 @@ class AbsenceBalance
|
|||||||
#[Groups(['absence_balance:read'])]
|
#[Groups(['absence_balance:read'])]
|
||||||
private float $pending = 0.0;
|
private float $pending = 0.0;
|
||||||
|
|
||||||
|
/** Last month (format YYYY-MM) for which the monthly accrual was applied. */
|
||||||
|
#[ORM\Column(length: 7, nullable: true)]
|
||||||
|
private ?string $lastAccruedMonth = null;
|
||||||
|
|
||||||
|
/** Total entitlement for the period, both finalized (N-1) and in-progress (N). */
|
||||||
|
#[Groups(['absence_balance:read'])]
|
||||||
|
public function getAcquiredTotal(): float
|
||||||
|
{
|
||||||
|
return $this->acquired + $this->acquiring;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days the employee can still take: in this organisation the days being
|
||||||
|
* accrued (N) are posable too, so they count towards what is available.
|
||||||
|
*/
|
||||||
#[Groups(['absence_balance:read'])]
|
#[Groups(['absence_balance:read'])]
|
||||||
public function getAvailable(): float
|
public function getAvailable(): float
|
||||||
{
|
{
|
||||||
return $this->acquired - $this->taken;
|
return $this->acquired + $this->acquiring - $this->taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Groups(['absence_balance:read'])]
|
#[Groups(['absence_balance:read'])]
|
||||||
@@ -137,6 +158,18 @@ class AbsenceBalance
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAcquiring(): float
|
||||||
|
{
|
||||||
|
return $this->acquiring;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAcquiring(float $acquiring): static
|
||||||
|
{
|
||||||
|
$this->acquiring = $acquiring;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTaken(): float
|
public function getTaken(): float
|
||||||
{
|
{
|
||||||
return $this->taken;
|
return $this->taken;
|
||||||
@@ -160,4 +193,16 @@ class AbsenceBalance
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLastAccruedMonth(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastAccruedMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastAccruedMonth(?string $lastAccruedMonth): static
|
||||||
|
{
|
||||||
|
$this->lastAccruedMonth = $lastAccruedMonth;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Repository\ClientTicketRepository;
|
|
||||||
use App\State\ClientTicketNumberProcessor;
|
|
||||||
use App\State\ClientTicketProvider;
|
|
||||||
use App\State\ClientTicketStatusProcessor;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
paginationEnabled: false,
|
|
||||||
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
|
|
||||||
provider: ClientTicketProvider::class,
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
|
|
||||||
provider: ClientTicketProvider::class,
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
security: "is_granted('ROLE_CLIENT')",
|
|
||||||
processor: ClientTicketNumberProcessor::class,
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)",
|
|
||||||
processor: ClientTicketStatusProcessor::class,
|
|
||||||
),
|
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
|
||||||
],
|
|
||||||
normalizationContext: ['groups' => ['client_ticket:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_ticket:write']],
|
|
||||||
order: ['createdAt' => 'DESC'],
|
|
||||||
)]
|
|
||||||
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
|
|
||||||
#[ORM\Table(name: 'client_ticket')]
|
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
|
|
||||||
class ClientTicket
|
|
||||||
{
|
|
||||||
public const string TYPE_BUG = 'bug';
|
|
||||||
public const string TYPE_IMPROVEMENT = 'improvement';
|
|
||||||
public const string TYPE_OTHER = 'other';
|
|
||||||
|
|
||||||
public const array TYPES = [
|
|
||||||
self::TYPE_BUG,
|
|
||||||
self::TYPE_IMPROVEMENT,
|
|
||||||
self::TYPE_OTHER,
|
|
||||||
];
|
|
||||||
|
|
||||||
public const string STATUS_NEW = 'new';
|
|
||||||
public const string STATUS_IN_PROGRESS = 'in_progress';
|
|
||||||
public const string STATUS_DONE = 'done';
|
|
||||||
public const string STATUS_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const array STATUSES = [
|
|
||||||
self::STATUS_NEW,
|
|
||||||
self::STATUS_IN_PROGRESS,
|
|
||||||
self::STATUS_DONE,
|
|
||||||
self::STATUS_REJECTED,
|
|
||||||
];
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['client_ticket:read', 'task:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer')]
|
|
||||||
#[Groups(['client_ticket:read', 'task:read'])]
|
|
||||||
private ?int $number = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 20)]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
|
||||||
#[Assert\Choice(choices: self::TYPES)]
|
|
||||||
private ?string $type = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
|
||||||
private ?string $title = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text')]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
|
||||||
private ?string $description = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
|
||||||
#[Assert\Url]
|
|
||||||
private ?string $url = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 20)]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
|
|
||||||
#[Assert\Choice(choices: self::STATUSES)]
|
|
||||||
private ?string $status = 'new';
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
|
||||||
private ?string $statusComment = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
#[Groups(['client_ticket:read', 'client_ticket:write'])]
|
|
||||||
private ?Project $project = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['client_ticket:read'])]
|
|
||||||
private ?User $submittedBy = null;
|
|
||||||
|
|
||||||
/** @var Collection<int, TaskDocument> */
|
|
||||||
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])]
|
|
||||||
#[Groups(['client_ticket:read'])]
|
|
||||||
private Collection $documents;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
|
||||||
#[Groups(['client_ticket:read'])]
|
|
||||||
private ?DateTimeImmutable $createdAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
|
||||||
#[Groups(['client_ticket:read'])]
|
|
||||||
private ?DateTimeImmutable $updatedAt = null;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->documents = new ArrayCollection();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNumber(): ?int
|
|
||||||
{
|
|
||||||
return $this->number;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setNumber(int $number): static
|
|
||||||
{
|
|
||||||
$this->number = $number;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getType(): ?string
|
|
||||||
{
|
|
||||||
return $this->type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setType(string $type): static
|
|
||||||
{
|
|
||||||
$this->type = $type;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTitle(): ?string
|
|
||||||
{
|
|
||||||
return $this->title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setTitle(string $title): static
|
|
||||||
{
|
|
||||||
$this->title = $title;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDescription(): ?string
|
|
||||||
{
|
|
||||||
return $this->description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDescription(string $description): static
|
|
||||||
{
|
|
||||||
$this->description = $description;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUrl(): ?string
|
|
||||||
{
|
|
||||||
return $this->url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setUrl(?string $url): static
|
|
||||||
{
|
|
||||||
$this->url = $url;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStatus(): ?string
|
|
||||||
{
|
|
||||||
return $this->status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setStatus(string $status): static
|
|
||||||
{
|
|
||||||
$this->status = $status;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStatusComment(): ?string
|
|
||||||
{
|
|
||||||
return $this->statusComment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setStatusComment(?string $statusComment): static
|
|
||||||
{
|
|
||||||
$this->statusComment = $statusComment;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getProject(): ?Project
|
|
||||||
{
|
|
||||||
return $this->project;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setProject(?Project $project): static
|
|
||||||
{
|
|
||||||
$this->project = $project;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSubmittedBy(): ?User
|
|
||||||
{
|
|
||||||
return $this->submittedBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSubmittedBy(?User $submittedBy): static
|
|
||||||
{
|
|
||||||
$this->submittedBy = $submittedBy;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, TaskDocument> */
|
|
||||||
public function getDocuments(): Collection
|
|
||||||
{
|
|
||||||
return $this->documents;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
|
||||||
{
|
|
||||||
$this->createdAt = $createdAt;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUpdatedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setUpdatedAt(DateTimeImmutable $updatedAt): static
|
|
||||||
{
|
|
||||||
$this->updatedAt = $updatedAt;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,11 +56,6 @@ class Notification
|
|||||||
#[Groups(['notification:read'])]
|
#[Groups(['notification:read'])]
|
||||||
private ?string $message = null;
|
private ?string $message = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['notification:read'])]
|
|
||||||
private ?ClientTicket $relatedTicket = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['notification:read', 'notification:write'])]
|
#[Groups(['notification:read', 'notification:write'])]
|
||||||
private bool $isRead = false;
|
private bool $isRead = false;
|
||||||
@@ -122,18 +117,6 @@ class Notification
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRelatedTicket(): ?ClientTicket
|
|
||||||
{
|
|
||||||
return $this->relatedTicket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRelatedTicket(?ClientTicket $relatedTicket): static
|
|
||||||
{
|
|
||||||
$this->relatedTicket = $relatedTicket;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isRead(): bool
|
public function isRead(): bool
|
||||||
{
|
{
|
||||||
return $this->isRead;
|
return $this->isRead;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
new Get(security: "is_granted('ROLE_USER')"),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
||||||
|
|||||||
@@ -124,11 +124,6 @@ class Task
|
|||||||
#[Groups(['task:read'])]
|
#[Groups(['task:read'])]
|
||||||
private Collection $documents;
|
private Collection $documents;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['task:read', 'task:write'])]
|
|
||||||
private ?ClientTicket $clientTicket = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
#[Groups(['task:read', 'task:write'])]
|
#[Groups(['task:read', 'task:write'])]
|
||||||
private ?DateTimeImmutable $scheduledStart = null;
|
private ?DateTimeImmutable $scheduledStart = null;
|
||||||
@@ -342,18 +337,6 @@ class Task
|
|||||||
return $this->documents;
|
return $this->documents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClientTicket(): ?ClientTicket
|
|
||||||
{
|
|
||||||
return $this->clientTicket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
|
||||||
{
|
|
||||||
$this->clientTicket = $clientTicket;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getScheduledStart(): ?DateTimeImmutable
|
public function getScheduledStart(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->scheduledStart;
|
return $this->scheduledStart;
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
|
||||||
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
|
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
processor: TaskDocumentProcessor::class,
|
processor: TaskDocumentProcessor::class,
|
||||||
deserialize: false,
|
deserialize: false,
|
||||||
),
|
),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
],
|
],
|
||||||
normalizationContext: ['groups' => ['task_document:read']],
|
normalizationContext: ['groups' => ['task_document:read']],
|
||||||
denormalizationContext: ['groups' => ['task_document:write']],
|
denormalizationContext: ['groups' => ['task_document:write']],
|
||||||
@@ -41,42 +41,37 @@ class TaskDocument
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
#[Groups(['task_document:read', 'task_document:write'])]
|
#[Groups(['task_document:read', 'task_document:write'])]
|
||||||
private ?Task $task = null;
|
private ?Task $task = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
|
||||||
#[Groups(['task_document:read', 'task_document:write'])]
|
|
||||||
private ?ClientTicket $clientTicket = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?string $originalName = null;
|
private ?string $originalName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?string $fileName = null;
|
private ?string $fileName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?string $mimeType = null;
|
private ?string $mimeType = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?int $size = null;
|
private ?int $size = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?DateTimeImmutable $createdAt = null;
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
#[Groups(['task_document:read', 'task:read'])]
|
||||||
private ?User $uploadedBy = null;
|
private ?User $uploadedBy = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -167,16 +162,4 @@ class TaskDocument
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClientTicket(): ?ClientTicket
|
|
||||||
{
|
|
||||||
return $this->clientTicket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
|
||||||
{
|
|
||||||
$this->clientTicket = $clientTicket;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,11 +85,6 @@ class TimeEntry
|
|||||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||||
private ?Task $task = null;
|
private ?Task $task = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
|
||||||
private ?ClientTicket $clientTicket = null;
|
|
||||||
|
|
||||||
/** @var Collection<int, TaskTag> */
|
/** @var Collection<int, TaskTag> */
|
||||||
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||||
#[ORM\JoinTable(
|
#[ORM\JoinTable(
|
||||||
@@ -194,18 +189,6 @@ class TimeEntry
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClientTicket(): ?ClientTicket
|
|
||||||
{
|
|
||||||
return $this->clientTicket;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
|
||||||
{
|
|
||||||
$this->clientTicket = $clientTicket;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, TaskTag> */
|
/** @return Collection<int, TaskTag> */
|
||||||
public function getTags(): Collection
|
public function getTags(): Collection
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use App\Repository\UserRepository;
|
|||||||
use App\State\MeProvider;
|
use App\State\MeProvider;
|
||||||
use App\State\UserPasswordHasherProcessor;
|
use App\State\UserPasswordHasherProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
@@ -50,11 +48,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180, unique: true)]
|
#[ORM\Column(length: 180, unique: true)]
|
||||||
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
|
||||||
private ?string $username = null;
|
private ?string $username = null;
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
@@ -78,17 +76,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $avatarFileName = null;
|
private ?string $avatarFileName = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Client::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
|
||||||
private ?Client $client = null;
|
|
||||||
|
|
||||||
/** @var Collection<int, Project> */
|
|
||||||
#[ORM\ManyToMany(targetEntity: Project::class)]
|
|
||||||
#[ORM\JoinTable(name: 'user_allowed_projects')]
|
|
||||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
|
||||||
private Collection $allowedProjects;
|
|
||||||
|
|
||||||
// --- HR / absence management fields ---
|
// --- HR / absence management fields ---
|
||||||
|
|
||||||
/** Whether this user is an employee subject to absence management. */
|
/** Whether this user is an employee subject to absence management. */
|
||||||
@@ -139,8 +126,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
$this->allowedProjects = new ArrayCollection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -168,11 +154,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public function getRoles(): array
|
public function getRoles(): array
|
||||||
{
|
{
|
||||||
$roles = $this->roles;
|
$roles = $this->roles;
|
||||||
|
$roles[] = 'ROLE_USER';
|
||||||
if (!in_array('ROLE_CLIENT', $roles, true)) {
|
|
||||||
$roles[] = 'ROLE_USER';
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($roles));
|
return array_values(array_unique($roles));
|
||||||
}
|
}
|
||||||
@@ -209,40 +192,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClient(): ?Client
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setClient(?Client $client): static
|
|
||||||
{
|
|
||||||
$this->client = $client;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, Project> */
|
|
||||||
public function getAllowedProjects(): Collection
|
|
||||||
{
|
|
||||||
return $this->allowedProjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addAllowedProject(Project $project): static
|
|
||||||
{
|
|
||||||
if (!$this->allowedProjects->contains($project)) {
|
|
||||||
$this->allowedProjects->add($project);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeAllowedProject(Project $project): static
|
|
||||||
{
|
|
||||||
$this->allowedProjects->removeElement($project);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getApiToken(): ?string
|
public function getApiToken(): ?string
|
||||||
{
|
{
|
||||||
return $this->apiToken;
|
return $this->apiToken;
|
||||||
@@ -267,7 +216,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
|
||||||
public function getAvatarUrl(): ?string
|
public function getAvatarUrl(): ?string
|
||||||
{
|
{
|
||||||
if (null === $this->avatarFileName) {
|
if (null === $this->avatarFileName) {
|
||||||
@@ -294,7 +243,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->plainPassword = null;
|
$this->plainPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isEmployee(): bool
|
public function getIsEmployee(): bool
|
||||||
{
|
{
|
||||||
return $this->isEmployee;
|
return $this->isEmployee;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Mcp\Tool;
|
namespace App\Mcp\Tool;
|
||||||
|
|
||||||
use App\Entity\ClientTicket;
|
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
use App\Entity\TaskDocument;
|
use App\Entity\TaskDocument;
|
||||||
@@ -253,39 +252,22 @@ final class Serializer
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return null|array{id: ?int, number: ?int, title: ?string}
|
|
||||||
*/
|
|
||||||
public static function clientTicketRef(?ClientTicket $ticket): ?array
|
|
||||||
{
|
|
||||||
if (null === $ticket) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $ticket->getId(),
|
|
||||||
'number' => $ticket->getNumber(),
|
|
||||||
'title' => $ticket->getTitle(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public static function timeEntry(TimeEntry $entry): array
|
public static function timeEntry(TimeEntry $entry): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $entry->getId(),
|
'id' => $entry->getId(),
|
||||||
'title' => $entry->getTitle(),
|
'title' => $entry->getTitle(),
|
||||||
'description' => $entry->getDescription(),
|
'description' => $entry->getDescription(),
|
||||||
'startedAt' => $entry->getStartedAt()?->format('c'),
|
'startedAt' => $entry->getStartedAt()?->format('c'),
|
||||||
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
||||||
'duration' => self::durationMinutes($entry),
|
'duration' => self::durationMinutes($entry),
|
||||||
'user' => self::user($entry->getUser()),
|
'user' => self::user($entry->getUser()),
|
||||||
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
||||||
'task' => self::taskRef($entry->getTask()),
|
'task' => self::taskRef($entry->getTask()),
|
||||||
'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
|
'tags' => self::tags($entry->getTags()),
|
||||||
'tags' => self::tags($entry->getTags()),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Mcp\Tool\TimeEntry;
|
|||||||
|
|
||||||
use App\Entity\TimeEntry;
|
use App\Entity\TimeEntry;
|
||||||
use App\Mcp\Tool\Serializer;
|
use App\Mcp\Tool\Serializer;
|
||||||
use App\Repository\ClientTicketRepository;
|
|
||||||
use App\Repository\ProjectRepository;
|
use App\Repository\ProjectRepository;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
@@ -31,7 +30,6 @@ class CreateTimeEntryTool
|
|||||||
private readonly TaskRepository $taskRepository,
|
private readonly TaskRepository $taskRepository,
|
||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepository $timeEntryRepository,
|
||||||
private readonly ClientTicketRepository $clientTicketRepository,
|
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,7 +42,6 @@ class CreateTimeEntryTool
|
|||||||
?int $taskId = null,
|
?int $taskId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
?string $description = null,
|
?string $description = null,
|
||||||
?int $clientTicketId = null,
|
|
||||||
): string {
|
): string {
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
@@ -90,13 +87,6 @@ class CreateTimeEntryTool
|
|||||||
}
|
}
|
||||||
$entry->setTask($task);
|
$entry->setTask($task);
|
||||||
}
|
}
|
||||||
if (null !== $clientTicketId) {
|
|
||||||
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
|
|
||||||
if (null === $clientTicket) {
|
|
||||||
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
|
|
||||||
}
|
|
||||||
$entry->setClientTicket($clientTicket);
|
|
||||||
}
|
|
||||||
if (null !== $tagIds) {
|
if (null !== $tagIds) {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$tag = $this->taskTagRepository->find($tagId);
|
$tag = $this->taskTagRepository->find($tagId);
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class ListTimeEntriesTool
|
|||||||
?int $userId = null,
|
?int $userId = null,
|
||||||
?int $projectId = null,
|
?int $projectId = null,
|
||||||
?int $taskId = null,
|
?int $taskId = null,
|
||||||
?int $clientTicketId = null,
|
|
||||||
?string $startDate = null,
|
?string $startDate = null,
|
||||||
?string $endDate = null,
|
?string $endDate = null,
|
||||||
int $limit = 100,
|
int $limit = 100,
|
||||||
@@ -39,7 +38,6 @@ class ListTimeEntriesTool
|
|||||||
->leftJoin('te.project', 'p')->addSelect('p')
|
->leftJoin('te.project', 'p')->addSelect('p')
|
||||||
->leftJoin('te.task', 't')->addSelect('t')
|
->leftJoin('te.task', 't')->addSelect('t')
|
||||||
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
||||||
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
|
|
||||||
->orderBy('te.startedAt', 'DESC')
|
->orderBy('te.startedAt', 'DESC')
|
||||||
->setMaxResults($limit)
|
->setMaxResults($limit)
|
||||||
;
|
;
|
||||||
@@ -53,9 +51,6 @@ class ListTimeEntriesTool
|
|||||||
if (null !== $taskId) {
|
if (null !== $taskId) {
|
||||||
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
||||||
}
|
}
|
||||||
if (null !== $clientTicketId) {
|
|
||||||
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
|
|
||||||
}
|
|
||||||
if (null !== $startDate) {
|
if (null !== $startDate) {
|
||||||
$qb->andWhere('te.startedAt >= :startDate')
|
$qb->andWhere('te.startedAt >= :startDate')
|
||||||
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
|
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Mcp\Tool\TimeEntry;
|
namespace App\Mcp\Tool\TimeEntry;
|
||||||
|
|
||||||
use App\Mcp\Tool\Serializer;
|
use App\Mcp\Tool\Serializer;
|
||||||
use App\Repository\ClientTicketRepository;
|
|
||||||
use App\Repository\ProjectRepository;
|
use App\Repository\ProjectRepository;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
@@ -27,7 +26,6 @@ class UpdateTimeEntryTool
|
|||||||
private readonly ProjectRepository $projectRepository,
|
private readonly ProjectRepository $projectRepository,
|
||||||
private readonly TaskRepository $taskRepository,
|
private readonly TaskRepository $taskRepository,
|
||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
private readonly ClientTicketRepository $clientTicketRepository,
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
@@ -41,7 +39,6 @@ class UpdateTimeEntryTool
|
|||||||
?int $taskId = null,
|
?int $taskId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
?string $description = null,
|
?string $description = null,
|
||||||
?int $clientTicketId = null,
|
|
||||||
): string {
|
): string {
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
@@ -79,13 +76,6 @@ class UpdateTimeEntryTool
|
|||||||
}
|
}
|
||||||
$entry->setTask($task);
|
$entry->setTask($task);
|
||||||
}
|
}
|
||||||
if (null !== $clientTicketId) {
|
|
||||||
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
|
|
||||||
if (null === $clientTicket) {
|
|
||||||
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
|
|
||||||
}
|
|
||||||
$entry->setClientTicket($clientTicket);
|
|
||||||
}
|
|
||||||
if (null !== $tagIds) {
|
if (null !== $tagIds) {
|
||||||
foreach ($entry->getTags()->toArray() as $existingTag) {
|
foreach ($entry->getTags()->toArray() as $existingTag) {
|
||||||
$entry->removeTag($existingTag);
|
$entry->removeTag($existingTag);
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository;
|
|
||||||
|
|
||||||
use App\Entity\ClientTicket;
|
|
||||||
use App\Entity\Project;
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends ServiceEntityRepository<ClientTicket>
|
|
||||||
*/
|
|
||||||
class ClientTicketRepository extends ServiceEntityRepository
|
|
||||||
{
|
|
||||||
public function __construct(ManagerRegistry $registry)
|
|
||||||
{
|
|
||||||
parent::__construct($registry, ClientTicket::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the max ticket number for a project, using an advisory lock
|
|
||||||
* to prevent race conditions when creating tickets concurrently.
|
|
||||||
*/
|
|
||||||
public function findMaxNumberByProjectForUpdate(Project $project): int
|
|
||||||
{
|
|
||||||
$conn = $this->getEntityManager()->getConnection();
|
|
||||||
|
|
||||||
// Use PostgreSQL advisory lock instead of FOR UPDATE
|
|
||||||
// because FOR UPDATE is not allowed with aggregate functions in PostgreSQL.
|
|
||||||
// Offset by 1000000 to avoid collision with task locks on the same project ID.
|
|
||||||
$conn->executeStatement(
|
|
||||||
'SELECT pg_advisory_xact_lock(:lockKey)',
|
|
||||||
['lockKey' => $project->getId() + 1000000],
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $conn->fetchOne(
|
|
||||||
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
|
|
||||||
['project' => $project->getId()],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (int) $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use DateTimeInterface;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
@@ -38,4 +39,24 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Employees active on the given date (hired on/before it, not yet left).
|
||||||
|
*
|
||||||
|
* @return User[]
|
||||||
|
*/
|
||||||
|
public function findActiveEmployees(DateTimeInterface $date): array
|
||||||
|
{
|
||||||
|
$dateStr = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('u')
|
||||||
|
->where('u.isEmployee = true')
|
||||||
|
->andWhere('u.hireDate IS NULL OR u.hireDate <= :date')
|
||||||
|
->andWhere('u.endDate IS NULL OR u.endDate >= :date')
|
||||||
|
->setParameter('date', $dateStr)
|
||||||
|
->orderBy('u.username', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ final readonly class MailAccessChecker
|
|||||||
/**
|
/**
|
||||||
* Verifie que l'utilisateur courant peut acceder aux endpoints mail.
|
* Verifie que l'utilisateur courant peut acceder aux endpoints mail.
|
||||||
* Autorise : ROLE_USER, ROLE_ADMIN.
|
* Autorise : ROLE_USER, ROLE_ADMIN.
|
||||||
* Refuse : ROLE_CLIENT pur (sans ROLE_ADMIN), non authentifie.
|
|
||||||
*
|
*
|
||||||
* @throws AccessDeniedException
|
* @throws AccessDeniedException
|
||||||
*/
|
*/
|
||||||
@@ -30,10 +29,6 @@ final readonly class MailAccessChecker
|
|||||||
|
|
||||||
$roles = $user->getRoles();
|
$roles = $user->getRoles();
|
||||||
|
|
||||||
if (in_array('ROLE_CLIENT', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
|
||||||
throw new AccessDeniedException('Mail not accessible to clients');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
if (!in_array('ROLE_USER', $roles, true) && !in_array('ROLE_ADMIN', $roles, true)) {
|
||||||
throw new AccessDeniedException('ROLE_USER required');
|
throw new AccessDeniedException('ROLE_USER required');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Entity\ClientTicket;
|
|
||||||
use App\Entity\Notification;
|
|
||||||
use App\Repository\UserRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
|
|
||||||
final readonly class NotificationService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private UserRepository $userRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify all ROLE_ADMIN users that a new ticket was created.
|
|
||||||
*/
|
|
||||||
public function createForTicketCreated(ClientTicket $ticket): void
|
|
||||||
{
|
|
||||||
$admins = $this->userRepository->findByRole('ROLE_ADMIN');
|
|
||||||
$number = sprintf('CT-%03d', $ticket->getNumber());
|
|
||||||
$projectName = $ticket->getProject()?->getName() ?? '';
|
|
||||||
|
|
||||||
foreach ($admins as $admin) {
|
|
||||||
$notification = new Notification();
|
|
||||||
$notification->setUser($admin);
|
|
||||||
$notification->setType('ticket_created');
|
|
||||||
$notification->setTitle('Nouveau ticket client '.$number);
|
|
||||||
$notification->setMessage($ticket->getTitle().' — '.$projectName);
|
|
||||||
$notification->setRelatedTicket($ticket);
|
|
||||||
$notification->setCreatedAt(new DateTimeImmutable());
|
|
||||||
|
|
||||||
$this->entityManager->persist($notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify the ticket submitter that the status has changed.
|
|
||||||
*/
|
|
||||||
public function createForStatusChange(ClientTicket $ticket): void
|
|
||||||
{
|
|
||||||
$submittedBy = $ticket->getSubmittedBy();
|
|
||||||
|
|
||||||
if (null === $submittedBy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$number = sprintf('CT-%03d', $ticket->getNumber());
|
|
||||||
$statusComment = $ticket->getStatusComment();
|
|
||||||
$message = 'Nouveau statut : '.$ticket->getStatus();
|
|
||||||
|
|
||||||
if (null !== $statusComment && '' !== $statusComment) {
|
|
||||||
$message .= ' — '.$statusComment;
|
|
||||||
}
|
|
||||||
|
|
||||||
$notification = new Notification();
|
|
||||||
$notification->setUser($submittedBy);
|
|
||||||
$notification->setType('ticket_status_changed');
|
|
||||||
$notification->setTitle('Ticket '.$number.' mis à jour');
|
|
||||||
$notification->setMessage($message);
|
|
||||||
$notification->setRelatedTicket($ticket);
|
|
||||||
$notification->setCreatedAt(new DateTimeImmutable());
|
|
||||||
|
|
||||||
$this->entityManager->persist($notification);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user