@@ -135,7 +150,53 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
- color: ''
+ color: '#222783'
+})
+
+const validationTouched = reactive({
+ code: false,
+ label: false,
+ color: false
+})
+
+const isCodeValid = computed(() => form.code.trim() !== '')
+const isLabelValid = computed(() => form.label.trim() !== '')
+const isColorValid = computed(() => form.color.trim() !== '')
+const isFormValid = computed(
+ () => isCodeValid.value && isLabelValid.value && isColorValid.value
+)
+
+const showCodeError = computed(() => validationTouched.code && !isCodeValid.value)
+const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
+const showColorError = computed(() => validationTouched.color && !isColorValid.value)
+
+const baseInputClass =
+ 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
+const codeFieldClass = computed(() => {
+ if (showCodeError.value) {
+ return `${baseInputClass} border-red-500`
+ }
+ return `${baseInputClass} border-neutral-300`
+})
+const labelFieldClass = computed(() => {
+ if (showLabelError.value) {
+ return `${baseInputClass} border-red-500`
+ }
+ return `${baseInputClass} border-neutral-300`
+})
+const colorFieldClass = computed(() => {
+ const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
+ if (showColorError.value) {
+ return `${baseColorClass} border-red-500`
+ }
+ return `${baseColorClass} border-neutral-300`
+})
+
+const submitButtonClass = computed(() => {
+ if (isSubmitting.value || !isFormValid.value) {
+ return 'opacity-50 cursor-not-allowed'
+ }
+ return ''
})
const loadAbsenceTypes = async () => {
@@ -152,7 +213,7 @@ onMounted(loadAbsenceTypes)
const resetForm = () => {
form.code = ''
form.label = ''
- form.color = ''
+ form.color = '#222783'
}
const openCreate = () => {
@@ -177,6 +238,10 @@ const closeDrawer = () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
+ validationTouched.code = true
+ validationTouched.label = true
+ validationTouched.color = true
+ if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -201,6 +266,14 @@ const handleSubmit = async () => {
}
}
+watch(isDrawerOpen, (isOpen) => {
+ if (!isOpen) {
+ validationTouched.code = false
+ validationTouched.label = false
+ validationTouched.color = false
+ }
+})
+
const confirmDelete = async (type: AbsenceType) => {
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
if (!ok) return
diff --git a/frontend/pages/calendar.vue b/frontend/pages/calendar.vue
index 61367bb..ef1fc1e 100644
--- a/frontend/pages/calendar.vue
+++ b/frontend/pages/calendar.vue
@@ -1,8 +1,10 @@
-
+
Calendrier des absences
-
+
+
+
+
+
-
-
-
-
- Employés
-
-
-
{{ day.label }}
-
{{ day.weekday }}
-
+
-
-
- {{ formatEmployeeName(employee) }}
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
@@ -249,17 +93,21 @@ import type {Absence} from '~/services/dto/absence'
import {listEmployees} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
+import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
+import CalendarGrid from '~/components/CalendarGrid.vue'
+import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
+import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
const employees = ref
([])
const sites = computed(() => {
- const map = new Map()
+ const siteMap = new Map()
for (const employee of employees.value) {
if (employee.site) {
- map.set(employee.site.id, employee.site)
+ siteMap.set(employee.site.id, employee.site)
}
}
- return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
+ return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
})
const selectedSiteIds = ref([])
@@ -272,16 +120,16 @@ watch(sites, (next) => {
}, { immediate: true })
const sortedEmployees = computed(() => {
- return [...employees.value].sort((a, b) => {
- const siteA = a.site?.name ?? ''
- const siteB = b.site?.name ?? ''
- if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
- const lastA = a.lastName ?? ''
- const lastB = b.lastName ?? ''
- if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
- const firstA = a.firstName ?? ''
- const firstB = b.firstName ?? ''
- return firstA.localeCompare(firstB, 'fr')
+ return [...employees.value].sort((employeeA, employeeB) => {
+ const siteNameA = employeeA.site?.name ?? ''
+ const siteNameB = employeeB.site?.name ?? ''
+ if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
+ const lastNameA = employeeA.lastName ?? ''
+ const lastNameB = employeeB.lastName ?? ''
+ if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
+ const firstNameA = employeeA.firstName ?? ''
+ const firstNameB = employeeB.firstName ?? ''
+ return firstNameA.localeCompare(firstNameB, 'fr')
})
})
@@ -293,6 +141,7 @@ const visibleEmployees = computed(() => {
})
const absenceTypes = ref([])
const absences = ref([])
+const publicHolidays = ref>({})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
@@ -318,10 +167,12 @@ const months = [
{value: 11, label: 'Décembre'}
]
-const years = Array.from({length: 5}, (_, i) => now.getFullYear() - 2 + i)
+const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
+const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
+const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
const gridStyle = computed(() => ({
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
@@ -423,33 +274,82 @@ const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
+const loadPublicHolidays = async () => {
+ publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
+}
+
const loadAbsences = async () => {
- absences.value = await listAbsences()
+ const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
+ const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
+ absences.value = await listAbsences({
+ from: monthStart,
+ to: monthEnd,
+ siteIds: selectedSiteIds.value
+ })
}
onMounted(async () => {
- await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
+ await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
})
-watch([selectedMonth, selectedYear], async () => {
+watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
})
-const getCellAbsence = (employeeId: number, date: string) => {
- const match = absences.value.find((absence) => {
- const employee = absence.employee?.id
- const start = normalizeDate(absence.startDate)
- const end = normalizeDate(absence.endDate)
- return Number(employee) === employeeId && date >= start && date <= end
- })
+watch(selectedYear, async () => {
+ await loadPublicHolidays()
+})
- if (!match) return null
+// Indexation des absences par cellule pour eviter un find() a chaque case.
+const cellAbsenceMap = computed(() => {
+ const map = new Map()
+ const monthStart = monthStartDate.value
+ const monthEnd = monthEndDate.value
- return {
- id: match.id,
- code: match.type?.code ?? '',
- color: match.type?.color ?? '#222783'
+ for (const absence of absences.value) {
+ const employeeId = absence.employee?.id
+ if (!employeeId) continue
+ const start = parseYmd(normalizeDate(absence.startDate))
+ const end = parseYmd(normalizeDate(absence.endDate))
+ if (!start || !end) continue
+
+ const rangeStart = start < monthStart ? monthStart : start
+ const rangeEnd = end > monthEnd ? monthEnd : end
+ if (rangeEnd < rangeStart) continue
+
+ for (
+ let currentDate = new Date(rangeStart.getTime());
+ currentDate <= rangeEnd;
+ currentDate.setDate(currentDate.getDate() + 1)
+ ) {
+ const key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
+ map.set(key, {
+ id: absence.id,
+ code: absence.type?.code ?? '',
+ color: absence.type?.color ?? '#222783'
+ })
+ }
}
+
+ return map
+})
+
+const isHolidayDate = (date: string) => {
+ return Boolean(publicHolidays.value[date])
+}
+
+const getCellAbsence = (employeeId: number, date: string) => {
+ if (isHolidayDate(date)) {
+ return {
+ id: 0,
+ code: 'F',
+ color: '#b3e5fc',
+ textColor: '#0f172a'
+ }
+ }
+ const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
+ if (absence) return absence
+ return null
}
const getCellStyle = (employeeId: number, date: string) => {
@@ -458,7 +358,7 @@ const getCellStyle = (employeeId: number, date: string) => {
return {
backgroundColor: absence.color,
- color: '#fff'
+ color: absence.textColor ?? '#fff'
}
}
@@ -467,6 +367,11 @@ const getCellCode = (employeeId: number, date: string) => {
}
const openCreate = (employee: Employee, date: string) => {
+ if (isHolidayDate(date)) {
+ window.alert("Impossible de creer une absence un jour ferie.")
+ return
+ }
+
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -498,12 +403,33 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
+ if (isHolidayDate(today)) {
+ window.alert("Impossible de creer une absence un jour ferie.")
+ return
+ }
form.startDate = today
form.endDate = today
form.comment = ''
isDrawerOpen.value = true
}
+const hasHolidayInRange = (startDate: string, endDate: string) => {
+ const start = parseYmd(startDate)
+ const end = parseYmd(endDate)
+ if (!start || !end) return false
+ for (
+ let currentDate = new Date(start.getTime());
+ currentDate <= end;
+ currentDate.setDate(currentDate.getDate() + 1)
+ ) {
+ const key = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
+ if (isHolidayDate(key)) {
+ return true
+ }
+ }
+ return false
+}
+
const handleSubmit = async () => {
if (isSubmitting.value) return
@@ -511,6 +437,10 @@ const handleSubmit = async () => {
try {
const start = normalizeDate(form.startDate)
const end = normalizeDate(form.endDate)
+ if (hasHolidayInRange(start, end)) {
+ window.alert("Impossible de creer une absence sur un jour ferie.")
+ return
+ }
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
@@ -519,8 +449,15 @@ const handleSubmit = async () => {
return start <= aEnd && end >= aStart
})
- for (const overlap of overlaps) {
- await deleteAbsence(overlap.id)
+ if (overlaps.length > 0) {
+ // Securise le chevauchement: on demande confirmation avant suppression.
+ const confirmReplace = window.confirm(
+ "Cette absence chevauche une autre. Voulez-vous la remplacer ?"
+ )
+ if (!confirmReplace) return
+ for (const overlap of overlaps) {
+ await deleteAbsence(overlap.id)
+ }
}
if (editingAbsence.value) {
@@ -552,8 +489,8 @@ const handleSubmit = async () => {
const handleDelete = async () => {
if (!editingAbsence.value) return
- const ok = window.confirm('Supprimer cette absence ?')
- if (!ok) return
+ const confirmDelete = window.confirm('Supprimer cette absence ?')
+ if (!confirmDelete) return
await deleteAbsence(editingAbsence.value.id)
closeDrawer()
diff --git a/frontend/pages/employees.vue b/frontend/pages/employees.vue
index 3f208e3..6c13d4d 100644
--- a/frontend/pages/employees.vue
+++ b/frontend/pages/employees.vue
@@ -60,35 +60,50 @@