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:
Matthieu
2026-05-22 11:31:31 +02:00
parent de98924fd3
commit 2a0b202d32
109 changed files with 3918 additions and 3656 deletions

View File

@@ -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)

View File

@@ -1,6 +1,6 @@
security: security:
role_hierarchy: role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT] ROLE_ADMIN: [ROLE_USER]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
@@ -64,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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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 -->

View File

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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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)"

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
<template> <template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')"> <MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
</template>
<form class="space-y-4" @submit.prevent="onSubmit"> <form class="space-y-4" @submit.prevent="onSubmit">
<div> <div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label> <label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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."
}
} }
} }

View File

@@ -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,6 +100,51 @@
: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
to="/absences"
icon="mdi:umbrella-beach-outline"
label="Mes absences"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
v-if="isAdmin"
to="/team-absences"
icon="mdi:calendar-account-outline"
label="Absences équipe"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Section : Administration (admin only) -->
<template v-if="isAdmin">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Administration
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink <SidebarLink
to="/admin" to="/admin"
icon="mdi:cog-outline" icon="mdi:cog-outline"
@@ -126,6 +152,7 @@
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @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)

View File

@@ -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>

View File

@@ -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')
} }
}) })

View File

@@ -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",

View File

@@ -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
View 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>

View File

@@ -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']

View File

@@ -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'
}) })

View File

@@ -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"
/> />

View File

@@ -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
} }

View File

@@ -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()
} }

View File

@@ -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"
/> />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')"
{{ $t('profile.changeAvatar') }} @click="avatarInput?.click()"
/>
<input <input
ref="avatarInput"
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif" accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden" class="hidden"
@change="onFileSelect" @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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/> />

View 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>

View File

@@ -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"
/> />

View 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,
}
}

View File

@@ -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 }
}

View 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
}

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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 }
} }

View File

@@ -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:

View 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');
}
}

View 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');
}
}

View 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)');
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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)) {

View File

@@ -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);

View File

@@ -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)
;
}
}

View File

@@ -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;
}
} }

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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']],

View File

@@ -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;

View File

@@ -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;
}
} }

View File

@@ -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
{ {

View File

@@ -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. */
@@ -140,7 +127,6 @@ 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
@@ -169,10 +155,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function getRoles(): array public function getRoles(): array
{ {
$roles = $this->roles; $roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER'; $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;
} }

View File

@@ -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,22 +252,6 @@ 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>
*/ */
@@ -284,7 +267,6 @@ final class Serializer
'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()),
]; ];
} }

View File

@@ -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);

View File

@@ -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'))

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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()
;
}
} }

View File

@@ -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');
} }

View File

@@ -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