feat(heures) : calendrier des jours validés (vue Jour) + harmonisation Malio UI

- Calendrier MalioDate en vue Jour (Heures + Heures Conducteurs) : jours
  entièrement validés (admin) peints en vert. Endpoint GET
  /work-hours/validation-status?from=&to=[&driver=1] (scope conducteur inversé),
  chargement à la volée par mois, refresh après validation/saisie/absence.
- Suite à @malio/layer-ui 1.7.11 : reserveMessageSpace=false sur les champs ;
  tous les drawers migrés sur MalioDrawer (titre via slot #header, AppDrawer
  custom supprimé) ; boutons d'action en MalioButton (deux boutons partagent
  l'espace) ; inputs date en MalioDate ; MalioDateWeek en vue Semaine.
- Boutons d'ajout uniformisés sur « Ajouter » + icône.
- .env : EXCLUDED_PUBLIC_HOLIDAYS="null".
- Doc : doc/hours-validated-days.md, documentation-content.ts, CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:47:23 +02:00
parent 5d2b5d1c54
commit 34dc52d92b
37 changed files with 1881 additions and 495 deletions
+54 -4
View File
@@ -15,6 +15,7 @@ import {
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWorkHourValidationStatus,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourSiteValidation,
@@ -28,7 +29,8 @@ import {
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
shiftYmd,
toYmd
} from '~/utils/date'
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
@@ -68,6 +70,9 @@ export const useDriverHoursPage = () => {
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
// Jours entièrement validés (conducteurs) par mois civil, pour le calendrier de
// la vue Jour. Clé 'YYYY-MM' → dates Y-m-d. Chargé à la volée sur @month-change.
const validatedDaysByMonth = ref<Record<string, string[]>>({})
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
@@ -519,10 +524,11 @@ export const useDriverHoursPage = () => {
const refreshAfterAbsenceChange = async () => {
if (isAdmin.value) {
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
} else {
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
}
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
await reloadValidationMonth(selectedDate.value)
}
const submitAbsence = async () => {
@@ -626,6 +632,7 @@ export const useDriverHoursPage = () => {
try {
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isValid = checked
await reloadValidationMonth(selectedDate.value)
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
@@ -708,6 +715,7 @@ export const useDriverHoursPage = () => {
}, { toast: false })
await loadWorkHours()
await reloadValidationMonth(selectedDate.value)
if (result.updated === 0) {
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
@@ -825,6 +833,45 @@ export const useDriverHoursPage = () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
// --- Calendrier vue Jour : jours validés en vert (scope conducteurs) ---------
const monthKey = (year: number, monthIndex: number) => `${year}-${String(monthIndex + 1).padStart(2, '0')}`
const markedDates = computed<Record<string, 'success'>>(() => {
const map: Record<string, 'success'> = {}
for (const days of Object.values(validatedDaysByMonth.value)) {
for (const day of days) map[day] = 'success'
}
return map
})
// Plage = grille visible complète (lundi avant le 1er → dimanche après le dernier).
// driver:true → l'endpoint ne considère que les conducteurs.
const loadValidationMonth = async (monthIndex: number, year: number, options: { force?: boolean } = {}) => {
const key = monthKey(year, monthIndex)
if (!options.force && validatedDaysByMonth.value[key]) return
const gridStart = getWeekStartDate(new Date(year, monthIndex, 1))
const gridEnd = getWeekStartDate(new Date(year, monthIndex + 1, 0))
gridEnd.setDate(gridEnd.getDate() + 6)
const from = toYmd(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate())
const to = toYmd(gridEnd.getFullYear(), gridEnd.getMonth(), gridEnd.getDate())
const days = await getWorkHourValidationStatus(from, to, { driver: true })
validatedDaysByMonth.value = { ...validatedDaysByMonth.value, [key]: days }
}
const onCalendarMonthChange = (payload: { month: number; year: number }) => {
void loadValidationMonth(payload.month, payload.year)
}
const reloadValidationMonth = async (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return
const key = monthKey(parsed.getFullYear(), parsed.getMonth())
if (!validatedDaysByMonth.value[key]) return
await loadValidationMonth(parsed.getMonth(), parsed.getFullYear(), { force: true })
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
@@ -921,6 +968,7 @@ export const useDriverHoursPage = () => {
})
await refreshByDate()
await reloadValidationMonth(selectedDate.value)
} finally {
isSubmitting.value = false
}
@@ -1003,6 +1051,8 @@ export const useDriverHoursPage = () => {
closeAbsenceDrawer,
formatMinutes,
handleSave,
markedDates,
onCalendarMonthChange,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
+60 -5
View File
@@ -16,6 +16,7 @@ import {
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWorkHourValidationStatus,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourSiteValidation,
@@ -30,7 +31,8 @@ import {
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
shiftYmd,
toYmd
} from '~/utils/date'
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
@@ -70,6 +72,10 @@ export const useHoursPage = () => {
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
// Jours entièrement validés (admin) par mois civil affiché dans le calendrier
// de la vue Jour. Clé = 'YYYY-MM', valeur = liste de dates Y-m-d. Chargé à la
// volée sur @month-change (jamais préchargé sur plusieurs années).
const validatedDaysByMonth = ref<Record<string, string[]>>({})
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
@@ -686,11 +692,11 @@ export const useHoursPage = () => {
const refreshAfterAbsenceChange = async () => {
if (isAdmin.value) {
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
} else {
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
}
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
await reloadValidationMonth(selectedDate.value)
}
const submitAbsence = async () => {
@@ -787,6 +793,7 @@ export const useHoursPage = () => {
try {
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isValid = checked
await reloadValidationMonth(selectedDate.value)
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
@@ -891,6 +898,7 @@ export const useHoursPage = () => {
}, { toast: false })
await loadWorkHours()
await reloadValidationMonth(selectedDate.value)
if (result.updated === 0) {
toast.error({
@@ -1031,6 +1039,50 @@ export const useHoursPage = () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
// --- Calendrier vue Jour : jours validés en vert ---------------------------
const monthKey = (year: number, monthIndex: number) => `${year}-${String(monthIndex + 1).padStart(2, '0')}`
// Fusionne tous les mois chargés en une seule map ISO → 'success' pour MalioDate.
const markedDates = computed<Record<string, 'success'>>(() => {
const map: Record<string, 'success'> = {}
for (const days of Object.values(validatedDaysByMonth.value)) {
for (const day of days) map[day] = 'success'
}
return map
})
// Charge le statut du mois affiché. La plage couvre toute la grille visible
// (lundi avant le 1er → dimanche après le dernier jour) pour colorer aussi les
// jours débordants des mois adjacents.
const loadValidationMonth = async (monthIndex: number, year: number, options: { force?: boolean } = {}) => {
const key = monthKey(year, monthIndex)
if (!options.force && validatedDaysByMonth.value[key]) return
const gridStart = getWeekStartDate(new Date(year, monthIndex, 1))
const gridEnd = getWeekStartDate(new Date(year, monthIndex + 1, 0))
gridEnd.setDate(gridEnd.getDate() + 6)
const from = toYmd(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate())
const to = toYmd(gridEnd.getFullYear(), gridEnd.getMonth(), gridEnd.getDate())
const days = await getWorkHourValidationStatus(from, to)
validatedDaysByMonth.value = { ...validatedDaysByMonth.value, [key]: days }
}
const onCalendarMonthChange = (payload: { month: number; year: number }) => {
void loadValidationMonth(payload.month, payload.year)
}
// Après une modification qui touche la validation d'un jour (validation,
// sauvegarde d'heures, absence), recharge le mois concerné s'il est déjà en
// cache → le calendrier se recolore. Sinon no-op (le prochain affichage fetch).
const reloadValidationMonth = async (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return
const key = monthKey(parsed.getFullYear(), parsed.getMonth())
if (!validatedDaysByMonth.value[key]) return
await loadValidationMonth(parsed.getMonth(), parsed.getFullYear(), { force: true })
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
@@ -1131,6 +1183,7 @@ export const useHoursPage = () => {
})
await refreshByDate()
await reloadValidationMonth(selectedDate.value)
} finally {
isSubmitting.value = false
}
@@ -1221,6 +1274,8 @@ export const useHoursPage = () => {
closeAbsenceDrawer,
formatMinutes,
handleSave,
markedDates,
onCalendarMonthChange,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,