Ajout des notification + page employé (#6)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #6 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #6.
This commit is contained in:
233
frontend/components/AppTopNav.vue
Normal file
233
frontend/components/AppTopNav.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<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">
|
||||
<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"/>
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<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` }"
|
||||
>
|
||||
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
||||
Notifications
|
||||
</div>
|
||||
<div class="flex gap-6 px-3 pb-2 border-b border-black">
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 cursor-pointer text-[18px]"
|
||||
:class="activeNotifTab === 'unread' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
||||
@click="switchNotifTab('unread')"
|
||||
>
|
||||
Non lues
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 cursor-pointer text-[18px]"
|
||||
:class="activeNotifTab === 'history' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
||||
@click="switchNotifTab('history')"
|
||||
>
|
||||
Historique
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="displayedNotifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
|
||||
Aucune notification.
|
||||
</div>
|
||||
<div v-else class="max-h-80 overflow-auto">
|
||||
<NuxtLink
|
||||
:to="notification.target"
|
||||
v-for="notification in displayedNotifications"
|
||||
:key="notification.id"
|
||||
class="flex gap-5 items-center border-b border-black px-3 py-4 last:border-b-0 relative hover:bg-tertiary-500"
|
||||
:class="notification.isRead ? '' : 'bg-tertiary-500'"
|
||||
>
|
||||
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
||||
<div class="flex flex-col min-w-0 text-[16px]">
|
||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
||||
</div>
|
||||
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
<div
|
||||
v-if="isUserMenuOpen"
|
||||
class="fixed right-[20px] z-30 w-60 rounded-md border border-neutral-200 bg-white text-[16px] text-black font-semibold shadow-lg"
|
||||
:style="{ top: `${navbarBottom + 20}px` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 border-b border-black"
|
||||
>
|
||||
Mon profil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 flex justify-between items-center"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<p>Déconnexion</p>
|
||||
<Icon name="mdi:logout-variant" size="20"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {User} from '~/services/dto/user'
|
||||
import type {NotificationItem} from '~/services/dto/notification'
|
||||
import {listUnreadNotifications, listTodayNotifications, listHistoryNotifications, markAllNotificationsRead} from '~/services/notifications'
|
||||
|
||||
defineProps<{
|
||||
user?: User
|
||||
}>()
|
||||
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
if (diffMinutes < 1) return "À l'instant"
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return `${diffHours} heure${diffHours > 1 ? 's' : ''}`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays} jour${diffDays > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
const bellRoot = ref<HTMLElement | null>(null)
|
||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||
const isUserMenuOpen = ref(false)
|
||||
const navbarBottom = ref(0)
|
||||
|
||||
const updateNavbarBottom = () => {
|
||||
if (headerRef.value) {
|
||||
navbarBottom.value = headerRef.value.getBoundingClientRect().bottom
|
||||
}
|
||||
}
|
||||
const todayNotifications = ref<NotificationItem[]>([])
|
||||
const historyNotifications = ref<NotificationItem[]>([])
|
||||
const isNotificationsOpen = ref(false)
|
||||
const isLoadingNotifications = ref(false)
|
||||
const activeNotifTab = ref<'unread' | 'history'>('unread')
|
||||
const unreadCount = computed(() => todayNotifications.value.length)
|
||||
const displayedNotifications = computed(() => activeNotifTab.value === 'unread' ? todayNotifications.value : historyNotifications.value)
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
updateNavbarBottom()
|
||||
isUserMenuOpen.value = !isUserMenuOpen.value
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
|
||||
const loadTodayNotifications = async () => {
|
||||
todayNotifications.value = await listTodayNotifications()
|
||||
}
|
||||
|
||||
const loadHistoryNotifications = async () => {
|
||||
historyNotifications.value = await listHistoryNotifications()
|
||||
}
|
||||
|
||||
const loadNotifications = async () => {
|
||||
isLoadingNotifications.value = true
|
||||
try {
|
||||
await loadTodayNotifications()
|
||||
} finally {
|
||||
isLoadingNotifications.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchNotifTab = async (tab: 'unread' | 'history') => {
|
||||
activeNotifTab.value = tab
|
||||
isLoadingNotifications.value = true
|
||||
try {
|
||||
if (tab === 'history') {
|
||||
await loadHistoryNotifications()
|
||||
} else {
|
||||
await loadTodayNotifications()
|
||||
}
|
||||
} finally {
|
||||
isLoadingNotifications.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeNotifications = async () => {
|
||||
if (!isNotificationsOpen.value) return
|
||||
isNotificationsOpen.value = false
|
||||
if (todayNotifications.value.length > 0) {
|
||||
await markAllNotificationsRead()
|
||||
todayNotifications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = async () => {
|
||||
if (isNotificationsOpen.value) {
|
||||
await closeNotifications()
|
||||
return
|
||||
}
|
||||
|
||||
updateNavbarBottom()
|
||||
activeNotifTab.value = 'unread'
|
||||
isNotificationsOpen.value = true
|
||||
await loadNotifications()
|
||||
}
|
||||
|
||||
const handleClickOutside = async (event: MouseEvent) => {
|
||||
const target = event.target as Node | null
|
||||
if (!target) return
|
||||
if (bellRoot.value && !bellRoot.value.contains(target)) {
|
||||
await closeNotifications()
|
||||
}
|
||||
if (userMenuRoot.value && !userMenuRoot.value.contains(target)) {
|
||||
isUserMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateNavbarBottom()
|
||||
if (isAdmin.value) {
|
||||
await loadNotifications()
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
async () => {
|
||||
if (!isAdmin.value) return
|
||||
await loadNotifications()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -11,12 +11,12 @@
|
||||
v-for="day in daysInMonth"
|
||||
:key="day.date"
|
||||
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
|
||||
:class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
||||
:class="isHoveredColumn(day.date) || day.date === today ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
||||
>
|
||||
<div>{{ day.label }}</div>
|
||||
<div
|
||||
class="text-[10px]"
|
||||
:class="isHoveredColumn(day.date) ? 'text-white/90' : 'text-neutral-500'"
|
||||
:class="isHoveredColumn(day.date) || day.date === today ? 'text-white/90' : 'text-neutral-500'"
|
||||
>
|
||||
{{ day.weekday }}
|
||||
</div>
|
||||
@@ -91,6 +91,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { HalfDay } from '~/services/dto/half-day'
|
||||
import { toYmd } from '~/utils/date'
|
||||
|
||||
const now = new Date()
|
||||
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
type DayInfo = {
|
||||
date: string
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
/>
|
||||
<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 })
|
||||
const model = defineModel<string>({required: true})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
placeholder: 'Chercher un employé (nom ou prénom)'
|
||||
placeholder: "Recherche d'un employé"
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,25 +1,69 @@
|
||||
<template>
|
||||
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
|
||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
|
||||
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
||||
<input
|
||||
:id="`site-${site.id}`"
|
||||
v-model="selectedSiteIds"
|
||||
:value="site.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<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="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>
|
||||
|
||||
248
frontend/components/employees/ContractTab.vue
Normal file
248
frontend/components/employees/ContractTab.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||
<p>Contrat</p>
|
||||
<p>Heures</p>
|
||||
<p>Date de début</p>
|
||||
<p>Date de fin</p>
|
||||
</div>
|
||||
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
|
||||
Aucun historique de contrat.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="item in contractHistory"
|
||||
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
||||
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
|
||||
>
|
||||
<p>{{ contractNatureLabel(item.contractNature) }}</p>
|
||||
<p>{{ contractHistoryLabel(item) }}</p>
|
||||
<p>{{ formatDate(item.startDate) }}</p>
|
||||
<p>{{ formatDate(item.endDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center gap-12">
|
||||
<button
|
||||
type="button"
|
||||
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
||||
@click="onOpenCloseContractDrawer"
|
||||
>
|
||||
Clôturer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
|
||||
@click="onOpenCreateContractDrawer"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
||||
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
||||
Type de contrat
|
||||
</label>
|
||||
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||
Temps de travail
|
||||
</label>
|
||||
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
||||
Début contrat
|
||||
</label>
|
||||
<input
|
||||
id="contract-start-date"
|
||||
:value="contractForm.startDate"
|
||||
type="date"
|
||||
:class="readonlyFieldClass"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
||||
Fin contrat <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contract-end-date"
|
||||
v-model="contractForm.endDate"
|
||||
type="date"
|
||||
:class="contractEndDateFieldClass"
|
||||
/>
|
||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
id="contract-comment"
|
||||
v-model="contractForm.comment"
|
||||
rows="3"
|
||||
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"
|
||||
placeholder="Motif de la clôture..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
|
||||
<input
|
||||
id="contract-paid-leave-settled"
|
||||
v-model="contractForm.paidLeaveSettled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Soldé dans le solde de tout compte
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||
:disabled="isContractSubmitting"
|
||||
@click="onUpdateContractDrawerOpen(false)"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
||||
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
||||
Type de contrat <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
|
||||
<option value="CDI">CDI</option>
|
||||
<option value="CDD">CDD</option>
|
||||
<option value="INTERIM">Intérim</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
||||
Temps de travail <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
|
||||
<option value="">Sélectionner un contrat</option>
|
||||
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
||||
{{ contract.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
|
||||
Début contrat <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
|
||||
</div>
|
||||
|
||||
<div v-if="requiresCreateContractEndDate">
|
||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
|
||||
Fin contrat <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||
:disabled="isCreateContractSubmitting"
|
||||
@click="onUpdateCreateContractDrawerOpen(false)"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contract } from '~/services/dto/contract'
|
||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||
|
||||
type ContractForm = {
|
||||
contractId: number | ''
|
||||
contractName: string
|
||||
weeklyHours: number | null
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
paidLeaveSettled: boolean
|
||||
comment: string
|
||||
}
|
||||
|
||||
type CreateContractForm = {
|
||||
contractId: number | ''
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
contractHistory: ContractHistoryItem[]
|
||||
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
||||
contractHistoryLabel: (item: ContractHistoryItem) => string
|
||||
formatDate: (value?: string | null) => string
|
||||
isContractSubmitting: boolean
|
||||
canCloseCurrentContract: boolean
|
||||
isCreateContractSubmitting: boolean
|
||||
contracts: Contract[]
|
||||
canCreateContract: boolean
|
||||
isContractDrawerOpen: boolean
|
||||
contractForm: ContractForm
|
||||
readonlyFieldClass: string
|
||||
closeContractWorkedHoursLabel: string
|
||||
contractEndDateFieldClass: string
|
||||
showContractEndDateError: boolean
|
||||
isContractEndDateValid: boolean
|
||||
isCreateContractDrawerOpen: boolean
|
||||
createContractForm: CreateContractForm
|
||||
createContractNatureFieldClass: string
|
||||
createContractFieldClass: string
|
||||
createContractStartDateFieldClass: string
|
||||
requiresCreateContractEndDate: boolean
|
||||
createContractEndDateFieldClass: string
|
||||
isCreateContractFormValid: boolean
|
||||
onOpenCloseContractDrawer: () => void
|
||||
onOpenCreateContractDrawer: () => void
|
||||
onUpdateContractDrawerOpen: (open: boolean) => void
|
||||
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
||||
onSubmitCloseContract: () => void
|
||||
onSubmitCreateContract: () => void
|
||||
}>()
|
||||
</script>
|
||||
309
frontend/components/employees/LeaveTab.vue
Normal file
309
frontend/components/employees/LeaveTab.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
|
||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||
formatCount(summary?.acquiredDays)
|
||||
}} Jours</p>
|
||||
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||
{{ formatCount(summary?.remainingDays) }} Jours</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
||||
<p><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
||||
<button
|
||||
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
|
||||
@click="openFractionedDrawer"
|
||||
>
|
||||
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
|
||||
</div>
|
||||
<div class="flex flex-col jutify-center gap-2 items-center py-3">
|
||||
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
||||
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<div class="grid grid-cols-4 gap-10">
|
||||
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
|
||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
||||
{{ month.label }}
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
|
||||
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
|
||||
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
|
||||
<div v-if="!day" class="h-6"/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="h-6 w-6"
|
||||
:class="getDayClass(day)"
|
||||
:style="getDayStyle(day)"
|
||||
:title="getDayTitle(day)"
|
||||
>
|
||||
{{ getDayText(day) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
|
||||
Nombre de jours <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="fractioned-days"
|
||||
v-model="fractionedForm.days"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||
@click="isFractionedDrawerOpen = false"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Absence} from '~/services/dto/absence'
|
||||
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
|
||||
import {normalizeDate, toYmd} from '~/utils/date'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
type DayLeaveState = {
|
||||
am: boolean
|
||||
pm: boolean
|
||||
labels: string[]
|
||||
colors: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
absences: Absence[]
|
||||
summary: EmployeeLeaveSummary | null
|
||||
publicHolidays: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update-fractioned-days', days: number): void
|
||||
}>()
|
||||
|
||||
const isFractionedDrawerOpen = ref(false)
|
||||
const fractionedForm = reactive({ days: 0 })
|
||||
|
||||
const openFractionedDrawer = () => {
|
||||
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
||||
isFractionedDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const handleSubmitFractioned = () => {
|
||||
const value = Number(fractionedForm.days)
|
||||
if (Number.isNaN(value) || value < 0) return
|
||||
emit('update-fractioned-days', value)
|
||||
isFractionedDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const monthLabels = [
|
||||
'Janvier',
|
||||
'Fevrier',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Aout',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Decembre'
|
||||
] as const
|
||||
|
||||
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
||||
|
||||
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
||||
|
||||
const displayedYear = computed(() => {
|
||||
if (props.summary?.year) return props.summary.year
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth() + 1
|
||||
return month >= 6 ? year + 1 : year
|
||||
})
|
||||
|
||||
const orderedMonthIndexes = computed(() => {
|
||||
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
|
||||
})
|
||||
|
||||
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
|
||||
|
||||
const dayLeaveMap = computed(() => {
|
||||
const map = new Map<string, DayLeaveState>()
|
||||
|
||||
for (const absence of props.absences) {
|
||||
const startYmd = normalizeDate(absence.startDate)
|
||||
const endYmd = normalizeDate(absence.endDate)
|
||||
const start = buildDateFromYmd(startYmd)
|
||||
const end = buildDateFromYmd(endYmd)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
|
||||
|
||||
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
||||
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
|
||||
const existing = map.get(ymd) ?? {
|
||||
am: false,
|
||||
pm: false,
|
||||
labels: [] as string[],
|
||||
colors: [] as string[]
|
||||
}
|
||||
|
||||
const isStart = ymd === startYmd
|
||||
const isEnd = ymd === endYmd
|
||||
const isSingleDay = startYmd === endYmd
|
||||
|
||||
let am = false
|
||||
let pm = false
|
||||
|
||||
if (isSingleDay) {
|
||||
am = absence.startHalf === 'AM'
|
||||
pm = absence.endHalf === 'PM'
|
||||
} else if (isStart) {
|
||||
am = absence.startHalf === 'AM'
|
||||
pm = true
|
||||
} else if (isEnd) {
|
||||
am = true
|
||||
pm = absence.endHalf === 'PM'
|
||||
} else {
|
||||
am = true
|
||||
pm = true
|
||||
}
|
||||
|
||||
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
|
||||
const typeColor = absence.type?.color ?? '#222783'
|
||||
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
|
||||
const hoverLabel = `${typeLabel}${halfSuffix}`
|
||||
|
||||
const colors = existing.colors.includes(typeColor)
|
||||
? existing.colors
|
||||
: [...existing.colors, typeColor]
|
||||
|
||||
map.set(ymd, {
|
||||
am: existing.am || am,
|
||||
pm: existing.pm || pm,
|
||||
labels: existing.labels.includes(hoverLabel)
|
||||
? existing.labels
|
||||
: [...existing.labels, hoverLabel],
|
||||
colors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const months = computed(() => {
|
||||
return orderedMonthIndexes.value.map((monthIndex) => {
|
||||
const label = monthLabels[monthIndex]
|
||||
const monthYear = isForfaitRule.value
|
||||
? displayedYear.value
|
||||
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
|
||||
|
||||
const first = new Date(monthYear, monthIndex, 1)
|
||||
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
|
||||
const mondayBasedFirstDay = (first.getDay() + 6) % 7
|
||||
|
||||
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null; isHoliday: boolean } | null> = []
|
||||
|
||||
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
const ymd = toYmd(monthYear, monthIndex, day)
|
||||
cells.push({
|
||||
ymd,
|
||||
label: String(day),
|
||||
leave: dayLeaveMap.value.get(ymd) ?? null,
|
||||
isHoliday: ymd in props.publicHolidays
|
||||
})
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
cells
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const getDayClass = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
||||
if (day.leave) {
|
||||
return 'rounded font-semibold text-white'
|
||||
}
|
||||
if (day.isHoliday) return 'text-primary-500 rounded font-semibold'
|
||||
return 'text-primary-500'
|
||||
}
|
||||
|
||||
const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
||||
if (day.leave) {
|
||||
const color = day.leave.colors[0] ?? '#222783'
|
||||
if (day.leave.am && day.leave.pm) {
|
||||
return { backgroundColor: color }
|
||||
}
|
||||
const colorFaded = `${color}60`
|
||||
const backgroundImage = day.leave.am
|
||||
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
||||
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
||||
return { backgroundImage, backgroundColor: 'transparent' }
|
||||
}
|
||||
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
|
||||
return day.label
|
||||
}
|
||||
|
||||
const getDayTitle = (day: { leave: DayLeaveState | null; isHoliday: boolean; ymd: string }) => {
|
||||
if (day.leave && day.leave.labels.length > 0) return day.leave.labels.join(' / ')
|
||||
if (day.isHoliday) return props.publicHolidays[day.ymd] ?? 'Jour férié'
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatCount = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
const rounded = Math.round(value * 100) / 100
|
||||
if (Number.isInteger(rounded)) return String(rounded)
|
||||
return rounded.toFixed(2).replace('.', ',')
|
||||
}
|
||||
</script>
|
||||
220
frontend/components/employees/RttTab.vue
Normal file
220
frontend/components/employees/RttTab.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
|
||||
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
||||
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
|
||||
+ Payer les RTT
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<div class="grid grid-cols-4 gap-10 pb-4">
|
||||
<div
|
||||
v-for="month in months"
|
||||
:key="month.month"
|
||||
class="rounded-md bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
|
||||
{{ month.label }}
|
||||
</div>
|
||||
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
|
||||
<template v-for="week in month.weeks" :key="week.key">
|
||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
|
||||
<span v-if="week.isEmpty"> </span>
|
||||
<span v-else>Semaine {{ week.weekNumber }}</span>
|
||||
</div>
|
||||
<div class="py-[6px] pl-3 border-b border-primary-500">
|
||||
<span v-if="week.isEmpty"> </span>
|
||||
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
|
||||
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
|
||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
|
||||
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
||||
@click="openEditPayment(month.month, '25')"
|
||||
title="Modifier les heures payées"
|
||||
>
|
||||
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
|
||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
||||
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
||||
@click="openEditPayment(month.month, '50')"
|
||||
title="Modifier les heures payées"
|
||||
>
|
||||
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
|
||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
|
||||
<form @submit.prevent="onSubmitPayment">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
||||
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
|
||||
<input v-model.number="paymentForm.hours" type="number" step="0.5" 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">Taux</label>
|
||||
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<option value="25">25%</option>
|
||||
<option value="50">50%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
|
||||
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: EmployeeRttSummary | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
|
||||
}>()
|
||||
|
||||
const isPaymentDrawerOpen = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
||||
|
||||
const monthLabels = [
|
||||
'Janvier',
|
||||
'Fevrier',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Aout',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Decembre'
|
||||
] as const
|
||||
|
||||
const orderedMonthOptions = [
|
||||
{ value: 6, label: 'Juin' },
|
||||
{ value: 7, label: 'Juillet' },
|
||||
{ value: 8, label: 'Aout' },
|
||||
{ value: 9, label: 'Septembre' },
|
||||
{ value: 10, label: 'Octobre' },
|
||||
{ value: 11, label: 'Novembre' },
|
||||
{ value: 12, label: 'Decembre' },
|
||||
{ value: 1, label: 'Janvier' },
|
||||
{ value: 2, label: 'Fevrier' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'Avril' },
|
||||
{ value: 5, label: 'Mai' }
|
||||
]
|
||||
|
||||
const paymentsByMonth = computed(() => {
|
||||
const map = new Map<number, { paid25: number; paid50: number }>()
|
||||
for (const mp of props.summary?.monthPayments ?? []) {
|
||||
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
||||
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
||||
|
||||
const months = computed(() => {
|
||||
type DisplayWeek = {
|
||||
key: string
|
||||
weekNumber: number
|
||||
recoveryMinutes: number
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
|
||||
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
|
||||
for (const month of orderedMonths) {
|
||||
byMonth.set(month, {
|
||||
month,
|
||||
label: monthLabels[month - 1],
|
||||
weeks: [],
|
||||
totalMinutes: 0
|
||||
})
|
||||
}
|
||||
|
||||
for (const week of props.summary?.weeks ?? []) {
|
||||
const month = byMonth.get(week.month)
|
||||
if (!month) continue
|
||||
|
||||
month.weeks.push({
|
||||
key: week.weekStart,
|
||||
weekNumber: week.weekNumber,
|
||||
recoveryMinutes: week.recoveryMinutes
|
||||
})
|
||||
month.totalMinutes += week.recoveryMinutes
|
||||
}
|
||||
|
||||
return orderedMonths
|
||||
.map((monthNumber) => byMonth.get(monthNumber)!)
|
||||
.filter(Boolean)
|
||||
.map((month) => {
|
||||
const minRows = 5
|
||||
const missing = Math.max(0, minRows - month.weeks.length)
|
||||
for (let i = 0; i < missing; i += 1) {
|
||||
month.weeks.push({
|
||||
key: `empty-${month.month}-${i}`,
|
||||
weekNumber: 0,
|
||||
recoveryMinutes: 0,
|
||||
isEmpty: true
|
||||
})
|
||||
}
|
||||
return month
|
||||
})
|
||||
})
|
||||
|
||||
const formatMinutes = (minutes: number) => {
|
||||
const abs = Math.abs(minutes)
|
||||
const hours = Math.floor(abs / 60)
|
||||
const rest = abs % 60
|
||||
const sign = minutes < 0 ? '-' : ''
|
||||
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const openNewPayment = () => {
|
||||
isEditMode.value = false
|
||||
paymentForm.month = 6
|
||||
paymentForm.hours = 0
|
||||
paymentForm.rate = '25'
|
||||
isPaymentDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const openEditPayment = (month: number, rate: '25' | '50') => {
|
||||
isEditMode.value = true
|
||||
paymentForm.month = month
|
||||
paymentForm.rate = rate
|
||||
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
||||
paymentForm.hours = currentMinutes / 60
|
||||
isPaymentDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onSubmitPayment = () => {
|
||||
const minutes = Math.round(paymentForm.hours * 60)
|
||||
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
||||
isPaymentDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,253 +1,267 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div class="overflow-y-auto min-h-0">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
<span class="pr-2">Fin après-midi</span>
|
||||
<span class="pl-2">Début soir</span>
|
||||
<span class="pr-2">Fin soir</span>
|
||||
<span class="pl-2">Jour</span>
|
||||
<span>Nuit</span>
|
||||
<span>Total</span>
|
||||
<span v-if="isAdmin" class="inline-flex items-center gap-2">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div class="overflow-y-auto min-h-0">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
<span class="pr-2">Fin après-midi</span>
|
||||
<span class="pl-2">Début soir</span>
|
||||
<span class="pr-2">Fin soir</span>
|
||||
<span class="pl-2">Jour</span>
|
||||
<span>Nuit</span>
|
||||
<span>Total</span>
|
||||
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||
<span>Valider</span>
|
||||
<input
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onBulkValidationChange"
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onBulkValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||
<span>Site</span>
|
||||
<input
|
||||
ref="bulkSiteValidationInput"
|
||||
:checked="isBulkSiteValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||
:disabled="!canBulkToggleSiteValidation"
|
||||
@change="onBulkSiteValidationChange"
|
||||
ref="bulkSiteValidationInput"
|
||||
:checked="isBulkSiteValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||
:disabled="!canBulkToggleSiteValidation"
|
||||
@change="onBulkSiteValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
</div>
|
||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
title="Validation site"
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
title="Validation site"
|
||||
>
|
||||
<Icon name="mdi:check"/>
|
||||
</span>
|
||||
</p>
|
||||
</p>
|
||||
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
||||
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||
<p
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
||||
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].morningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
<input
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].morningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].afternoonFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
<input
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].afternoonTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].eveningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].eveningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{
|
||||
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{
|
||||
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{
|
||||
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="text-right">
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-right p-5">
|
||||
<input
|
||||
v-if="isSiteManager"
|
||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
<div v-if="!isAdmin">
|
||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||
<p
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
||||
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].morningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
<input
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].morningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].afternoonFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
<input
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].afternoonTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].eveningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
v-model="rows[employee.id].eveningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-neutral-700">
|
||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
||||
</div>
|
||||
<div v-if="isAdmin">
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<input
|
||||
v-if="isSiteManager"
|
||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!canToggleSiteValidation(employee.id) || isSiteValidationPending(employee.id)"
|
||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
<div v-if="!isAdmin">
|
||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type {Employee} from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type { HourRow } from './types'
|
||||
import type {HourRow} from './types'
|
||||
|
||||
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
||||
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
contractLabel: (employee: Employee) => string
|
||||
isTimeTracking: (employee: Employee) => boolean
|
||||
isPresenceTracking: (employee: Employee) => boolean
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
isBulkSiteValidationChecked: boolean
|
||||
isBulkSiteValidationIndeterminate: boolean
|
||||
canBulkToggleSiteValidation: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
contractLabel: (employee: Employee) => string
|
||||
isTimeTracking: (employee: Employee) => boolean
|
||||
isPresenceTracking: (employee: Employee) => boolean
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
isBulkSiteValidationChecked: boolean
|
||||
isBulkSiteValidationIndeterminate: boolean
|
||||
canBulkToggleSiteValidation: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
const onBulkValidationChange = (event: Event) => {
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onBulkSiteValidationChange = (event: Event) => {
|
||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkValidationInput.value) return
|
||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkValidationInput.value) return
|
||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.isBulkSiteValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkSiteValidationInput.value) return
|
||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
() => props.isBulkSiteValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkSiteValidationInput.value) return
|
||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="py-6 flex flex-col gap-3">
|
||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
@@ -99,10 +104,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||
class="flex flex-wrap items-center gap-6"
|
||||
|
||||
@@ -10,4 +10,5 @@ export type HourRow = {
|
||||
isPresentAfternoon: boolean
|
||||
isSiteValid: boolean
|
||||
isValid: boolean
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
@@ -167,8 +167,9 @@ const closeMenu = () => {
|
||||
|
||||
const commitInput = () => {
|
||||
const normalized = normalizeTypedTime(inputValue.value)
|
||||
if (normalized === null) {
|
||||
inputValue.value = props.modelValue
|
||||
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
||||
emit('update:modelValue', '')
|
||||
inputValue.value = ''
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user