feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
This commit is contained in:
@@ -442,6 +442,17 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ecran-recap-conges',
|
||||
title: 'Écran Récap. congés',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
||||
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
||||
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
"create": "Impossible de créer l'observation.",
|
||||
"update": "Impossible de mettre à jour l'observation.",
|
||||
"delete": "Impossible de supprimer l'observation."
|
||||
},
|
||||
"leaveRecap": {
|
||||
"load": "Impossible de charger le récap des congés."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
<Icon name="mdi:account-group-outline" size="24"/>
|
||||
<p>Employés</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="hasLeaveRecapAccess"
|
||||
to="/leave-recap"
|
||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/leave-recap')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
<Icon name="mdi:beach" size="24"/>
|
||||
<p>Récap. congés</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
@@ -84,6 +95,15 @@
|
||||
<p>Utilisateurs</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-if="hasLeaveRecapAccess && !isAdmin"
|
||||
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' : ''"
|
||||
>
|
||||
<Icon name="mdi:beach" size="24"/>
|
||||
<p>Récap. congés</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isSuperAdmin"
|
||||
to="/audit-logs"
|
||||
@@ -128,5 +148,6 @@ const {version} = useAppVersion()
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
|
||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
11
frontend/middleware/leave-recap-access.ts
Normal file
11
frontend/middleware/leave-recap-access.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!auth.user?.hasLeaveRecapAccess) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
121
frontend/pages/leave-recap.vue
Normal file
121
frontend/pages/leave-recap.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<Icon name="mdi:calendar-check-outline" size="18"/>
|
||||
{{ cutoffLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||
>
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="rows.length === 0"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||
>
|
||||
Aucun employé à afficher.
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||
<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`"
|
||||
>
|
||||
<span v-if="showSiteColumn" class="text-left">Site</span>
|
||||
<span class="text-left">Nom</span>
|
||||
<span class="text-left">Prénom</span>
|
||||
<span class="text-left">Contrat</span>
|
||||
<span class="text-right">CP N-1 restant</span>
|
||||
<span class="text-right">CP N</span>
|
||||
<span class="text-right">Samedis</span>
|
||||
<span class="text-right">RTT</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.employeeId"
|
||||
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
|
||||
>
|
||||
<span v-if="showSiteColumn" class="truncate">
|
||||
<span
|
||||
v-if="row.siteName"
|
||||
class="inline-block rounded-full px-3 py-1 text-sm"
|
||||
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
|
||||
>
|
||||
{{ row.siteName }}
|
||||
</span>
|
||||
<span v-else class="text-neutral-500">-</span>
|
||||
</span>
|
||||
<span class="truncate">{{ row.lastName }}</span>
|
||||
<span class="truncate">{{ row.firstName }}</span>
|
||||
<span class="truncate">{{ row.contractName ?? '-' }}</span>
|
||||
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.cpN }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.rtt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
|
||||
import { fetchLeaveRecap } from '~/services/leave-recap'
|
||||
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
|
||||
|
||||
definePageMeta({ middleware: ['leave-recap-access'] })
|
||||
useHead({ title: 'Récap. congés' })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const rows = ref<LeaveRecapRow[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const isSelfOnly = computed(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
const showSiteColumn = computed(() => !isSelfOnly.value)
|
||||
const gridColsClass = computed(() =>
|
||||
showSiteColumn.value
|
||||
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
|
||||
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
|
||||
)
|
||||
|
||||
const cutoffLabel = computed(() => {
|
||||
const ymd = rows.value[0]?.cutoffDate
|
||||
if (!ymd) return ''
|
||||
const parsed = parseYmd(ymd)
|
||||
if (!parsed) return ''
|
||||
const week = getIsoWeekNumber(parsed)
|
||||
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
|
||||
})
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) return '-'
|
||||
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
rows.value = await fetchLeaveRecap()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// Silence unused linter warning for isAdmin (kept for future site grouping)
|
||||
void isAdmin
|
||||
</script>
|
||||
@@ -189,6 +189,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.hasLeaveRecapAccess"
|
||||
type="checkbox"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -233,7 +247,8 @@ const form = reactive({
|
||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
||||
employeeId: '' as number | '',
|
||||
siteIds: [] as number[],
|
||||
isLocked: false
|
||||
isLocked: false,
|
||||
hasLeaveRecapAccess: false
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -345,6 +360,7 @@ const resetForm = () => {
|
||||
form.accessMode = 'admin'
|
||||
form.siteIds = []
|
||||
form.isLocked = false
|
||||
form.hasLeaveRecapAccess = false
|
||||
editingUser.value = null
|
||||
validationTouched.username = false
|
||||
validationTouched.password = false
|
||||
@@ -373,6 +389,7 @@ const openEdit = (user: User) => {
|
||||
|
||||
form.employeeId = user.employee?.id ?? ''
|
||||
form.isLocked = user.isLocked
|
||||
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
|
||||
|
||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
||||
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
||||
@@ -427,7 +444,8 @@ const handleSubmit = async () => {
|
||||
plainPassword: form.password.trim() ? form.password : undefined,
|
||||
roles,
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
isLocked: form.isLocked,
|
||||
hasLeaveRecapAccess: form.hasLeaveRecapAccess
|
||||
})
|
||||
|
||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
||||
@@ -452,7 +470,8 @@ const handleSubmit = async () => {
|
||||
plainPassword: form.password,
|
||||
roles,
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
isLocked: form.isLocked,
|
||||
hasLeaveRecapAccess: form.hasLeaveRecapAccess
|
||||
})
|
||||
|
||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
||||
|
||||
14
frontend/services/dto/leave-recap.ts
Normal file
14
frontend/services/dto/leave-recap.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type LeaveRecapRow = {
|
||||
employeeId: number
|
||||
lastName: string
|
||||
firstName: string
|
||||
siteId: number | null
|
||||
siteName: string | null
|
||||
siteColor: string | null
|
||||
contractName: string | null
|
||||
cpN1Remaining: number
|
||||
cpN: string
|
||||
acquiredSaturdays: string
|
||||
rtt: string
|
||||
cutoffDate: string
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export type UserData = {
|
||||
username: string
|
||||
roles: string[]
|
||||
isDriver: boolean
|
||||
hasLeaveRecapAccess: boolean
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ export type User = {
|
||||
username: string
|
||||
roles: string[]
|
||||
isLocked: boolean
|
||||
hasLeaveRecapAccess: boolean
|
||||
employee?: Employee | null
|
||||
}
|
||||
|
||||
12
frontend/services/leave-recap.ts
Normal file
12
frontend/services/leave-recap.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LeaveRecapRow } from './dto/leave-recap'
|
||||
import { extractItems } from '~/utils/api'
|
||||
|
||||
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
|
||||
const api = useApi()
|
||||
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
|
||||
'/leave-recap',
|
||||
{},
|
||||
{ toastErrorKey: 'errors.leaveRecap.load' }
|
||||
)
|
||||
return extractItems<LeaveRecapRow>(data)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const createUser = async (payload: {
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
hasLeaveRecapAccess?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<User>(
|
||||
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
|
||||
plainPassword: payload.plainPassword,
|
||||
roles: payload.roles,
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||
isLocked: payload.isLocked ?? false
|
||||
isLocked: payload.isLocked ?? false,
|
||||
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
|
||||
},
|
||||
{
|
||||
toastSuccessKey: 'success.user.create',
|
||||
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
hasLeaveRecapAccess?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
const body: Record<string, unknown> = {
|
||||
username: payload.username,
|
||||
roles: payload.roles,
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||
isLocked: payload.isLocked ?? false
|
||||
isLocked: payload.isLocked ?? false,
|
||||
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
|
||||
}
|
||||
|
||||
if (payload.plainPassword) {
|
||||
|
||||
Reference in New Issue
Block a user