Compare commits
8 Commits
v0.1.92
...
bd93c52197
| Author | SHA1 | Date | |
|---|---|---|---|
| bd93c52197 | |||
|
|
90843dd997 | ||
| 8a449cf81b | |||
|
|
3926946a5f | ||
| b9c3a8a84f | |||
|
|
b2f6fdf222 | ||
| 0fe82c63c5 | |||
| 849d19f124 |
@@ -3,6 +3,7 @@
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
!docker/php/config/php.ini
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
## Stack
|
||||
- Backend: Symfony + API Platform + Doctrine ORM
|
||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
|
||||
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
|
||||
|
||||
## Project Structure
|
||||
- `src/` — Symfony domain, API resources, state providers/processors, services
|
||||
@@ -32,6 +33,8 @@
|
||||
- Contract nature (per period): CDI, CDD, INTERIM
|
||||
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
|
||||
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
||||
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
|
||||
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
|
||||
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.92'
|
||||
app.version: '0.1.95'
|
||||
|
||||
@@ -58,6 +58,9 @@ Documents complementaires:
|
||||
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
|
||||
- non mise à jour lors de modifications admin ou chef de site
|
||||
- affichée sous le nom de l'employé (visible admin uniquement)
|
||||
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
|
||||
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
|
||||
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
|
||||
|
||||
## 4) Absences
|
||||
|
||||
@@ -71,6 +74,10 @@ Documents complementaires:
|
||||
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
||||
- demi-journée: dégradé diagonal
|
||||
- journée complète: fond plein
|
||||
- Visibilité des employés dans le Calendrier:
|
||||
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
|
||||
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
||||
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
|
||||
|
||||
### Effet absence sur les heures
|
||||
|
||||
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||
@@ -1,44 +1,26 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
||||
Employé <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="employee"
|
||||
v-model="absenceForm.employeeId"
|
||||
:class="employeeFieldClass"
|
||||
:disabled="props.lockEmployee"
|
||||
>
|
||||
<option value="" disabled>Choisir un employé</option>
|
||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
|
||||
L'employé est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
||||
:options="employeeOptions"
|
||||
label="Employé *"
|
||||
empty-option-label="Choisir un employé"
|
||||
min-width=""
|
||||
:disabled="props.lockEmployee"
|
||||
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
|
||||
@update:model-value="onEmployeeChange"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="type">
|
||||
Type d'absence <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
v-model="absenceForm.typeId"
|
||||
:class="typeFieldClass"
|
||||
>
|
||||
<option value="" disabled>Choisir un type</option>
|
||||
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
|
||||
{{ type.label }} ({{ type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
|
||||
Le type d'absence est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
||||
:options="typeOptions"
|
||||
label="Type d'absence *"
|
||||
empty-option-label="Choisir un type"
|
||||
min-width=""
|
||||
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
|
||||
@update:model-value="onTypeChange"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
@@ -48,17 +30,15 @@
|
||||
id="start-date"
|
||||
v-model="absenceForm.startDate"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<select
|
||||
v-model="absenceForm.startHalf"
|
||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||
{{ half.label }}
|
||||
</option>
|
||||
</select>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.startHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -68,17 +48,15 @@
|
||||
id="end-date"
|
||||
v-model="absenceForm.endDate"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<select
|
||||
v-model="absenceForm.endHalf"
|
||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||
{{ half.label }}
|
||||
</option>
|
||||
</select>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.endHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,13 +88,12 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="props.isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
@@ -189,20 +166,23 @@ const submitButtonClass = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const baseSelectClass =
|
||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||
const employeeFieldClass = computed(() => {
|
||||
if (showEmployeeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
const typeFieldClass = computed(() => {
|
||||
if (showTypeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
const employeeOptions = computed(() =>
|
||||
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||
)
|
||||
const typeOptions = computed(() =>
|
||||
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
|
||||
)
|
||||
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
||||
|
||||
const dateInputBaseClass =
|
||||
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
||||
|
||||
const onEmployeeChange = (value: string | number | null) => {
|
||||
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
||||
}
|
||||
const onTypeChange = (value: string | number | null) => {
|
||||
absenceForm.value.typeId = value === null ? '' : Number(value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||
<div class="flex h-full items-center justify-end">
|
||||
<div class="flex gap-6 text-xl text-white">
|
||||
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
|
||||
<div class="flex h-full items-center justify-between lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
|
||||
@click="$emit('toggleSidebar')"
|
||||
>
|
||||
<Icon name="mdi:menu" size="28"/>
|
||||
</button>
|
||||
<div class="flex gap-4 text-xl text-white lg:gap-6">
|
||||
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
||||
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
|
||||
<Icon name="mdi:bell-plus" size="36"/>
|
||||
@@ -15,8 +22,8 @@
|
||||
|
||||
<div
|
||||
v-if="isNotificationsOpen"
|
||||
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
|
||||
:style="{ top: `${navbarBottom + 20}px` }"
|
||||
class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
|
||||
:style="{ top: `${navbarBottom + 10}px` }"
|
||||
>
|
||||
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
||||
Notifications
|
||||
@@ -66,7 +73,7 @@
|
||||
<div ref="userMenuRoot" class="relative flex gap-4">
|
||||
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
||||
<p class="self-center">{{ user?.username }}</p>
|
||||
<p class="hidden self-center sm:block">{{ user?.username }}</p>
|
||||
</button>
|
||||
<div
|
||||
v-if="isUserMenuOpen"
|
||||
@@ -103,6 +110,10 @@ defineProps<{
|
||||
user?: User
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(event: 'toggleSidebar'): void
|
||||
}>()
|
||||
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px]">
|
||||
<input
|
||||
id="employee-search"
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
|
||||
/>
|
||||
<Icon
|
||||
name="mdi:magnify"
|
||||
size="18"
|
||||
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>({required: true})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
placeholder: "Recherche d'un employé"
|
||||
})
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="relative inline-block w-fit max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span>Sites</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
|
||||
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="z-50 absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:for="`site-${site.id}`"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
|
||||
>
|
||||
<input
|
||||
:id="`site-${site.id}`"
|
||||
v-model="selectedSiteIds"
|
||||
:value="site.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-md text-neutral-800">{{ site.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { Site } from '~/services/dto/site'
|
||||
|
||||
const selectedSiteIds = defineModel<number[]>({ required: true })
|
||||
const isOpen = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
sites: Site[]
|
||||
}>()
|
||||
|
||||
const selectedCount = computed(() => selectedSiteIds.value.length)
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null
|
||||
if (!root.value || !target) return
|
||||
if (!root.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -43,7 +43,7 @@
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
@@ -205,6 +205,7 @@ const props = defineProps<{
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
|
||||
@@ -149,13 +149,13 @@
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Reste row -->
|
||||
@@ -187,41 +187,41 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base25Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus25Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base50Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus50Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
@@ -500,10 +500,10 @@ const paymentForm = reactive({
|
||||
const prefillFromExistingPayment = (month: number) => {
|
||||
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
|
||||
if (existing) {
|
||||
paymentForm.base25Hours = existing.paidBase25Minutes / 60
|
||||
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
|
||||
paymentForm.base50Hours = existing.paidBase50Minutes / 60
|
||||
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
|
||||
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
|
||||
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
|
||||
} else {
|
||||
paymentForm.base25Hours = 0
|
||||
paymentForm.bonus25Hours = 0
|
||||
@@ -516,6 +516,14 @@ watch(() => paymentForm.month, (newMonth) => {
|
||||
prefillFromExistingPayment(newMonth)
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base25Hours, (value) => {
|
||||
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base50Hours, (value) => {
|
||||
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
|
||||
})
|
||||
|
||||
const openPaymentDrawer = () => {
|
||||
paymentForm.month = currentMonth.value
|
||||
prefillFromExistingPayment(currentMonth.value)
|
||||
|
||||
@@ -1,6 +1,174 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div class="overflow-y-auto min-h-0">
|
||||
<!-- Mobile card layout -->
|
||||
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="'m-' + employee.id"
|
||||
class="rounded-md border border-primary-500 bg-white p-4"
|
||||
>
|
||||
<!-- Employee name + site -->
|
||||
<div class="mb-3">
|
||||
<p class="text-md font-bold text-primary-500 truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-sm text-neutral-500 truncate">
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Absence / Holiday / Formation pills -->
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<p
|
||||
v-if="getRowAbsenceLabel(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white truncate"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-neutral-400"
|
||||
>
|
||||
Aucune absence
|
||||
</p>
|
||||
<p
|
||||
v-if="isHoliday"
|
||||
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
|
||||
style="background-color: #b3e5fc"
|
||||
>
|
||||
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasRowFormation(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="!hasRowFormation(employee.id)"
|
||||
type="button"
|
||||
class="self-start text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time inputs (TIME tracking) -->
|
||||
<div v-if="isTimeTracking(employee)" class="space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
|
||||
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
|
||||
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
|
||||
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presence tracking -->
|
||||
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
Matin
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
Après-midi
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation status (non-admin) -->
|
||||
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
|
||||
<span v-if="!isSiteManager" class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Validation checkbox (admin) -->
|
||||
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
:checked="rows[employee.id]?.isValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-neutral-700 font-semibold">Valider</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table layout -->
|
||||
<div class="overflow-y-auto min-h-0 hidden lg:block">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
@@ -44,7 +212,7 @@
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
@@ -237,6 +405,7 @@ const props = defineProps<{
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
hasRowFormation: (employeeId: number) => boolean
|
||||
getRowFormationLabel: (employeeId: number) => string
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
|
||||
@@ -1,17 +1,88 @@
|
||||
<template>
|
||||
<div class="py-6 flex flex-col gap-3">
|
||||
<div class="flex gap-4">
|
||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||
<div class="py-4 flex flex-col gap-3 lg:py-6">
|
||||
<!-- Desktop: filters row -->
|
||||
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
||||
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="w-80">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<!-- Mobile: search + filter button -->
|
||||
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
|
||||
@click="filtersDrawerOpen = true"
|
||||
>
|
||||
<Icon name="mdi:filter-variant" size="22"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filters drawer -->
|
||||
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
|
||||
<div class="space-y-6">
|
||||
<div v-if="sites.length > 0 && isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
||||
<div class="mt-2">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Vue</label>
|
||||
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('day')"
|
||||
@click="viewMode = 'day'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-clock" />
|
||||
Jour
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('week')"
|
||||
@click="viewMode = 'week'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-week" />
|
||||
Semaine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<!-- Date navigation -->
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
|
||||
<div
|
||||
v-if="viewMode === 'day'"
|
||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,7 +112,7 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -70,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[320px]"
|
||||
width-class="w-full lg:w-[320px]"
|
||||
:label="formattedSelectedDate"
|
||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||
:picker-value="pickerValue"
|
||||
@@ -82,7 +153,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<!-- Desktop: view mode toggle -->
|
||||
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
@@ -106,7 +178,7 @@
|
||||
|
||||
<div
|
||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||
class="flex flex-wrap items-center gap-6"
|
||||
class="hidden lg:flex flex-wrap items-center gap-6"
|
||||
>
|
||||
<p class="font-bold">Légende :</p>
|
||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||
@@ -120,9 +192,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||
|
||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||
@@ -130,7 +201,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
||||
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
|
||||
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean
|
||||
sites: Site[]
|
||||
absenceTypes: AbsenceType[]
|
||||
@@ -140,6 +211,8 @@ defineProps<{
|
||||
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||
}>()
|
||||
|
||||
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'set-yesterday'): void
|
||||
(e: 'set-today'): void
|
||||
@@ -150,6 +223,8 @@ const emit = defineEmits<{
|
||||
(e: 'shift-date', value: number): void
|
||||
}>()
|
||||
|
||||
const filtersDrawerOpen = ref(false)
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||
return selectedDate.value
|
||||
|
||||
@@ -1,7 +1,71 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||
<div v-else class="overflow-y-auto min-h-0">
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="row in weeklySummary?.rows ?? []"
|
||||
:key="'m-' + row.employeeId"
|
||||
class="rounded-md border border-primary-500 bg-white p-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<p class="text-md font-bold text-primary-500 truncate">
|
||||
{{ row.firstName }} {{ row.lastName }}
|
||||
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 truncate">
|
||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily breakdown -->
|
||||
<div class="mb-3 space-y-1">
|
||||
<div
|
||||
v-for="(daily, i) in row.daily"
|
||||
:key="daily.date"
|
||||
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
|
||||
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
>
|
||||
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
|
||||
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
|
||||
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly totals -->
|
||||
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Total sem.</span>
|
||||
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">H. supp.</span>
|
||||
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">+25%</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">+50%</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">Récup.</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
|
||||
<span class="text-neutral-500">Panier nuit</span>
|
||||
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||
:disabled="props.disabled"
|
||||
@mousedown.prevent
|
||||
@click="toggleOpen"
|
||||
@@ -149,8 +149,11 @@ const toggleOpen = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = () => window.innerWidth < 1024
|
||||
|
||||
const openMenu = () => {
|
||||
if (props.disabled) return
|
||||
if (isMobile()) return
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
nextTick(updateMenuPosition)
|
||||
@@ -165,8 +168,28 @@ const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const snapToNearest15 = (time: string): string => {
|
||||
const [h, m] = time.split(':').map(Number)
|
||||
const snapped = Math.round(m / 15) * 15
|
||||
if (snapped === 60) {
|
||||
const newH = h + 1
|
||||
if (newH > 23) return '23:45'
|
||||
return `${String(newH).padStart(2, '0')}:00`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const commitInput = () => {
|
||||
const normalized = normalizeTypedTime(inputValue.value)
|
||||
let value = inputValue.value
|
||||
if (isMobile()) {
|
||||
value = clampTime(value)
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized !== null && normalized !== '') {
|
||||
value = snapToNearest15(normalized)
|
||||
}
|
||||
inputValue.value = value
|
||||
}
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
||||
emit('update:modelValue', '')
|
||||
inputValue.value = ''
|
||||
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
|
||||
if (masked !== inputValue.value) {
|
||||
inputValue.value = masked
|
||||
}
|
||||
openMenu()
|
||||
if (!isMobile()) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const clampTime = (value: string): string => {
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized === null || normalized === '') return value
|
||||
const [h, m] = normalized.split(':').map(Number)
|
||||
if (h > 23 || (h === 23 && m > 45)) return '23:45'
|
||||
return normalized
|
||||
}
|
||||
|
||||
const onInputBlur = () => {
|
||||
// Laisse le temps au click menu de passer avant fermeture.
|
||||
setTimeout(() => {
|
||||
if (menu.value?.contains(document.activeElement)) return
|
||||
if (isMobile()) {
|
||||
inputValue.value = clampTime(inputValue.value)
|
||||
}
|
||||
commitInput()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
@@ -417,6 +417,10 @@ export const useDriverHoursPage = () => {
|
||||
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
||||
}
|
||||
|
||||
const hasContractAtSelectedDate = (employeeId: number) => {
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
if (!dayRow) return true
|
||||
@@ -982,6 +986,7 @@ export const useDriverHoursPage = () => {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
|
||||
@@ -494,6 +494,10 @@ export const useHoursPage = () => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
|
||||
}
|
||||
|
||||
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
||||
}
|
||||
|
||||
const getRowUpdatedAt = (employeeId: number): string => {
|
||||
const raw = rows.value[employeeId]?.updatedAt
|
||||
if (!raw) return ''
|
||||
@@ -1174,6 +1178,7 @@ export const useHoursPage = () => {
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
|
||||
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
|
||||
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
|
||||
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -388,6 +389,7 @@ export const documentationSections: DocSection[] = [
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
|
||||
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
|
||||
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
|
||||
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||
<div class="h-[75px]">
|
||||
<!-- Mobile overlay -->
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
]"
|
||||
>
|
||||
<div class="flex h-[75px] items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<Icon name="mdi:close" size="24"/>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<nav class="flex-1 overflow-y-auto px-4 pb-6">
|
||||
<template v-if="isAdmin">
|
||||
<NuxtLink
|
||||
to="/calendar"
|
||||
@@ -13,6 +42,7 @@
|
||||
:class="route.path.startsWith('/calendar')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:calendar-blank" size="24"/>
|
||||
<p>Calendrier</p>
|
||||
@@ -26,6 +56,7 @@
|
||||
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
|
||||
]"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||
<p>Heures</p>
|
||||
@@ -38,6 +69,7 @@
|
||||
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
|
||||
]"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:truck-outline" size="24"/>
|
||||
<p>Heures Conducteurs</p>
|
||||
@@ -49,6 +81,7 @@
|
||||
:class="route.path.startsWith('/employees')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:account-group-outline" size="24"/>
|
||||
<p>Employés</p>
|
||||
@@ -60,6 +93,7 @@
|
||||
:class="route.path.startsWith('/leave-recap')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:beach" size="24"/>
|
||||
<p>Récap. congés</p>
|
||||
@@ -70,6 +104,7 @@
|
||||
:class="route.path.startsWith('/sites')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:business" size="24"/>
|
||||
<p>Sites</p>
|
||||
@@ -80,6 +115,7 @@
|
||||
:class="route.path.startsWith('/absence-types')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:umbrella-beach-outline" size="24"/>
|
||||
<p>Types de statut</p>
|
||||
@@ -90,6 +126,7 @@
|
||||
:class="route.path.startsWith('/users')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="24"/>
|
||||
<p>Utilisateurs</p>
|
||||
@@ -100,6 +137,7 @@
|
||||
to="/leave-recap"
|
||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
|
||||
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:beach" size="24"/>
|
||||
<p>Récap. congés</p>
|
||||
@@ -111,6 +149,7 @@
|
||||
:class="route.path.startsWith('/audit-logs')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
|
||||
<p>Journal</p>
|
||||
@@ -121,6 +160,7 @@
|
||||
:class="route.path.startsWith('/documentation')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
@click="closeSidebarOnMobile"
|
||||
>
|
||||
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
|
||||
<p>Documentation</p>
|
||||
@@ -132,9 +172,9 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex-1 overflow-y-auto px-8 py-12">
|
||||
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
|
||||
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
|
||||
<main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12">
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -150,4 +190,9 @@ const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN
|
||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
|
||||
const route = useRoute()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const closeSidebarOnMobile = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
extends: ['@malio/layer-ui'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
|
||||
260
frontend/package-lock.json
generated
260
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@malio/layer-ui": "^1.4.3",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.0",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
|
||||
@@ -6,29 +6,39 @@
|
||||
<div class="flex flex-col gap-3 py-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
||||
<div class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
label="Ajouter une absence"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateFromToday"
|
||||
>
|
||||
+ Ajouter une absence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Imprimer"
|
||||
variant="secondary"
|
||||
icon-name="mdi:printer"
|
||||
icon-position="left"
|
||||
@click="openPrint"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-80">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[260px]"
|
||||
@@ -111,9 +121,7 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
|
||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Calendrier'
|
||||
@@ -136,6 +144,8 @@ const sites = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
// Filtres de sites (par défaut: tous sélectionnés à l'init).
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
@@ -154,12 +164,27 @@ const sortedEmployees = computed(() => {
|
||||
// Employés visibles selon le filtre de sites.
|
||||
const employeeFilter = ref('')
|
||||
|
||||
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
|
||||
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
|
||||
const hasContractInSelectedMonth = (employee: Employee): boolean => {
|
||||
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
||||
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
||||
const history = employee.contractHistory ?? []
|
||||
if (history.length === 0) return false
|
||||
return history.some((period) => {
|
||||
const start = period.startDate
|
||||
const end = period.endDate ?? '9999-12-31'
|
||||
return start <= monthEnd && end >= monthStart
|
||||
})
|
||||
}
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
const filter = employeeFilter.value.trim().toLowerCase()
|
||||
return sortedEmployees.value.filter((employee) => {
|
||||
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
||||
if (!siteOk) return false
|
||||
if (!hasContractInSelectedMonth(employee)) return false
|
||||
if (!filter) return true
|
||||
const first = employee.firstName?.toLowerCase() ?? ''
|
||||
const last = employee.lastName?.toLowerCase() ?? ''
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
:get-row-metrics="getRowMetrics"
|
||||
:get-row-absence-label="getRowAbsenceLabel"
|
||||
:get-row-absence-style="getRowAbsenceStyle"
|
||||
:get-row-contract-nature="getRowContractNature"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
:format-minutes="formatMinutes"
|
||||
@@ -169,6 +170,7 @@ const {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
|
||||
@@ -34,19 +34,29 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 py-7">
|
||||
<div class="flex items-center gap-3 py-7">
|
||||
<div class="w-80">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
||||
<select
|
||||
<div v-if="sites.length > 0" class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="contractStatusFilter"
|
||||
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
|
||||
>
|
||||
<option value="active">Avec contrat</option>
|
||||
<option value="inactive">Sans contrat</option>
|
||||
<option value="all">Tous</option>
|
||||
</select>
|
||||
label="Statut contrat"
|
||||
:options="contractStatusOptions"
|
||||
group-class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,7 +285,6 @@ import {listContracts} from '~/services/contracts'
|
||||
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
||||
import {listSites} from '~/services/sites'
|
||||
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
|
||||
import BulkYearlyHoursDrawer from '~/components/BulkYearlyHoursDrawer.vue'
|
||||
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
||||
@@ -304,7 +313,13 @@ const contracts = ref<Contract[]>([])
|
||||
const interimAgencies = ref<InterimAgency[]>([])
|
||||
const employeeFilter = ref('')
|
||||
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
|
||||
const contractStatusOptions = [
|
||||
{ label: 'Avec contrat', value: 'active' },
|
||||
{ label: 'Sans contrat', value: 'inactive' },
|
||||
{ label: 'Tous', value: 'all' }
|
||||
]
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
const filteredEmployees = computed<Employee[]>(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-full overflow-hidden flex flex-col">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
|
||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||
</div>
|
||||
|
||||
<HoursToolbar
|
||||
@@ -70,6 +70,7 @@
|
||||
:get-row-absence-style="getRowAbsenceStyle"
|
||||
:has-row-formation="hasRowFormation"
|
||||
:get-row-formation-label="getRowFormationLabel"
|
||||
:get-row-contract-nature="getRowContractNature"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:get-presence-day-value="getPresenceDayValue"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
@@ -184,6 +185,7 @@ const {
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
|
||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
|
||||
<span
|
||||
v-if="cutoffLabel"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
|
||||
@@ -25,7 +25,8 @@
|
||||
Aucun employé à afficher.
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||
<!-- Desktop table -->
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
|
||||
<div
|
||||
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
|
||||
>
|
||||
@@ -64,6 +65,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="'m-' + row.employeeId"
|
||||
class="rounded-md border border-primary-500 bg-white p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<p class="text-md font-bold text-primary-500 truncate">
|
||||
{{ row.lastName }} {{ row.firstName }}
|
||||
</p>
|
||||
<span
|
||||
v-if="showSiteColumn && row.siteName"
|
||||
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
|
||||
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
|
||||
>
|
||||
{{ row.siteName }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">CP N-1</span>
|
||||
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Samedis</span>
|
||||
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">CP N</span>
|
||||
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">RTT</span>
|
||||
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between pb-6">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
|
||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
Aucun utilisateur pour le moment.
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||
<!-- Desktop table -->
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
|
||||
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span class="text-left">Utilisateur</span>
|
||||
<span class="text-left">Employé</span>
|
||||
@@ -56,6 +57,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="'m-' + user.id"
|
||||
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
|
||||
@click="openEdit(user)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
|
||||
<span
|
||||
v-if="user.isLocked"
|
||||
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
|
||||
>Actif</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p v-if="user.employee" class="text-neutral-600">
|
||||
{{ user.employee.firstName }} {{ user.employee.lastName }}
|
||||
</p>
|
||||
<p class="text-neutral-500">
|
||||
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
|
||||
</p>
|
||||
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
|
||||
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppDrawer
|
||||
v-model="isDrawerOpen"
|
||||
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
|
||||
|
||||
@@ -111,6 +111,7 @@ export type WorkHourDayContextRow = {
|
||||
hasFormation?: boolean
|
||||
formationLabel?: string | null
|
||||
virtualHolidayMinutes?: number
|
||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
}
|
||||
|
||||
export type WorkHourDayContext = {
|
||||
|
||||
@@ -20,6 +20,7 @@ final class DayContextRow
|
||||
public bool $hasFormation = false,
|
||||
public ?string $formationLabel = null,
|
||||
public int $virtualHolidayMinutes = 0,
|
||||
public ?string $contractNature = null,
|
||||
) {}
|
||||
|
||||
public function setFormation(string $label): void
|
||||
@@ -77,7 +78,8 @@ final class DayContextRow
|
||||
* isDriverContract:bool,
|
||||
* hasFormation:bool,
|
||||
* formationLabel:?string,
|
||||
* virtualHolidayMinutes:int
|
||||
* virtualHolidayMinutes:int,
|
||||
* contractNature:?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@@ -96,6 +98,7 @@ final class DayContextRow
|
||||
'hasFormation' => $this->hasFormation,
|
||||
'formationLabel' => $this->formationLabel,
|
||||
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
||||
'contractNature' => $this->contractNature,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,13 +57,17 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
||||
}
|
||||
|
||||
// On initialise toutes les lignes, même sans absence ce jour-là.
|
||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
|
||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
|
||||
$contractNature = null !== $contract
|
||||
? $this->contractResolver->resolveNatureForEmployeeAndDate($employee, $workDate)->value
|
||||
: null;
|
||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
||||
employeeId: $employeeId,
|
||||
hasContractAtDate: null !== $contract,
|
||||
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
|
||||
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
|
||||
contractNature: $contractNature,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Entity\AbsenceType;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
@@ -176,6 +177,10 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
->method('resolveForEmployeeAndDate')
|
||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||
;
|
||||
$resolver
|
||||
->method('resolveNatureForEmployeeAndDate')
|
||||
->willReturn(ContractNature::CDI)
|
||||
;
|
||||
|
||||
return $resolver;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user