refactor(frontend) : reorganize components into subdirectories and fix imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 23:59:24 +01:00
parent d28f385918
commit 1efa0fa9ca
30 changed files with 9 additions and 9 deletions

View File

@@ -0,0 +1,241 @@
<template>
<div
ref="blockEl"
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
:style="blockStyle"
:class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)"
@mousedown="onMouseDown"
@click.stop
>
<!-- Resize handle top (outside block) -->
<div
class="absolute left-0 right-0 h-3 cursor-n-resize group"
style="bottom: 100%"
@mousedown.stop.prevent="onResizeTopStart"
>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
<div class="px-1.5 py-0.5 h-full overflow-hidden">
<!-- Full display: title + project + type dot + duration -->
<template v-if="sizeLevel >= 3">
<div class="flex items-center gap-1">
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
<div v-if="entry.types.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
<span
v-for="type in entry.types"
:key="type.id"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
>
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: type.color }" />
{{ type.label }}
</span>
</div>
</template>
<!-- Medium: title + duration -->
<template v-else-if="sizeLevel === 2">
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
</template>
<!-- Small: title only -->
<template v-else-if="sizeLevel === 1">
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
</template>
<!-- Tiny: just a colored bar, no text -->
</div>
<!-- Resize handle bottom (outside block) -->
<div
class="absolute left-0 right-0 h-3 cursor-s-resize group"
style="top: 100%"
@mousedown.stop.prevent="onResizeBottomStart"
>
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entry: TimeEntry
hourHeight: number
dayStartHour: number
isDragSource?: boolean
columnIndex?: number
totalColumns?: number
}>()
const emit = defineEmits<{
(e: 'click', entry: TimeEntry): void
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
}>()
const blockEl = ref<HTMLElement | null>(null)
const startDate = computed(() => new Date(props.entry.startedAt))
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
const resizeTopDeltaMinutes = ref(0)
const resizeBottomDeltaMinutes = ref(0)
const duration = computed(() => {
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
})
const heightPx = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
})
// Responsive content levels based on block height
// 3 = full (title + project + types + duration)
// 2 = medium (title + duration)
// 1 = small (title only)
// 0 = tiny (colored bar only)
const sizeLevel = computed(() => {
const h = heightPx.value
if (h >= 50) return 3
if (h >= 35) return 2
if (h >= 20) return 1
return 0
})
const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const bgColor = props.entry.project?.color ?? '#94a3b8'
const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1
const gapPx = 2
const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100
return {
top: `${topPx}px`,
height: `${heightPx.value}px`,
backgroundColor: bgColor,
left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
}
})
// --- Click / Drag detection ---
let mouseDownPos = { x: 0, y: 0 }
let mouseDownHandled = false
function onMouseDown(event: MouseEvent) {
if (event.button !== 0) return
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
mouseDownPos = { x: event.clientX, y: event.clientY }
mouseDownHandled = false
document.addEventListener('mousemove', onMouseMoveDetect)
document.addEventListener('mouseup', onMouseUpDetect)
}
function onMouseMoveDetect(event: MouseEvent) {
const dx = event.clientX - mouseDownPos.x
const dy = event.clientY - mouseDownPos.y
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
mouseDownHandled = true
document.removeEventListener('mousemove', onMouseMoveDetect)
document.removeEventListener('mouseup', onMouseUpDetect)
const rect = blockEl.value!.getBoundingClientRect()
emit('moveStart', {
entry: props.entry,
offsetY: mouseDownPos.y - rect.top,
})
}
}
function onMouseUpDetect() {
document.removeEventListener('mousemove', onMouseMoveDetect)
document.removeEventListener('mouseup', onMouseUpDetect)
if (!mouseDownHandled) {
emit('click', props.entry)
}
}
// --- Resize bottom (change stoppedAt) ---
function onResizeBottomStart(event: MouseEvent) {
const startY = event.clientY
resizeBottomDeltaMinutes.value = 0
document.body.style.userSelect = 'none'
document.body.style.cursor = 's-resize'
function onMouseMove(e: MouseEvent) {
const delta = e.clientY - startY
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
const finalDelta = resizeBottomDeltaMinutes.value
resizeBottomDeltaMinutes.value = 0
if (finalDelta !== 0) {
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
}
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// --- Resize top (change startedAt) ---
function onResizeTopStart(event: MouseEvent) {
const startY = event.clientY
resizeTopDeltaMinutes.value = 0
document.body.style.userSelect = 'none'
document.body.style.cursor = 'n-resize'
function onMouseMove(e: MouseEvent) {
const delta = e.clientY - startY
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
const finalDelta = resizeTopDeltaMinutes.value
resizeTopDeltaMinutes.value = 0
if (finalDelta !== 0) {
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
}
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuEl"
class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
:style="{ top: `${y}px`, left: `${x}px` }"
>
<button
v-if="entry"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
@click="onCopy"
>
<Icon name="mdi:content-copy" size="16" />
Copier
</button>
<button
v-if="canPaste"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
@click="onPaste"
>
<Icon name="mdi:content-paste" size="16" />
Coller
</button>
<button
v-if="entry"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
@click="onDelete"
>
<Icon name="mdi:delete-outline" size="16" />
Supprimer
</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
visible: boolean
x: number
y: number
entry?: TimeEntry | null
canPaste: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'copy', entry: TimeEntry): void
(e: 'paste'): void
(e: 'delete', entry: TimeEntry): void
}>()
const menuEl = ref<HTMLElement | null>(null)
function onCopy() {
if (props.entry) emit('copy', props.entry)
emit('close')
}
function onPaste() {
emit('paste')
emit('close')
}
function onDelete() {
if (props.entry) emit('delete', props.entry)
emit('close')
}
function onClickOutside(event: MouseEvent) {
if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
emit('close')
}
}
watch(() => props.visible, (v) => {
if (v) {
setTimeout(() => document.addEventListener('click', onClickOutside), 0)
} else {
document.removeEventListener('click', onClickOutside)
}
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>

View File

@@ -0,0 +1,245 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
<input
v-model="form.title"
type="text"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Que fais-tu ?"
/>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
<textarea
v-model="form.description"
rows="3"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
<input
v-model="form.date"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Début</label>
<input
v-model="form.startTime"
type="time"
step="60"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Fin</label>
<input
v-model="form.endTime"
type="time"
step="60"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div
v-if="durationLabel"
class="rounded-md bg-neutral-100 px-3 py-2 text-center text-sm font-semibold text-neutral-600 tabular-nums"
>
{{ durationLabel }}
</div>
<MalioSelect
v-model="form.userId"
:options="userOptions"
label="Utilisateur"
min-width="w-full"
/>
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet"
empty-option-label=" Aucun "
min-width="w-full"
/>
<MalioSelect
v-model="form.typeId"
:options="typeOptions"
label="Type"
empty-option-label=" Aucun "
min-width="w-full"
/>
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
<button
v-if="isEditing"
type="button"
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskType } from '~/services/dto/task-type'
import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{
modelValue: boolean
entry?: TimeEntry | null
prefillStartedAt?: string | null
users: UserData[]
projects: Project[]
types: TaskType[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.entry)
const authStore = useAuthStore()
const form = reactive({
title: '',
description: '',
date: '',
startTime: '',
endTime: '',
userId: authStore.user?.id ?? null as number | null,
projectId: null as number | null,
typeId: null as number | null,
})
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const projectOptions = computed(() =>
props.projects.map(p => ({ label: p.name, value: p.id }))
)
const typeOptions = computed(() =>
props.types.map(t => ({ label: t.label, value: t.id }))
)
const durationLabel = computed(() => {
if (!form.startTime || !form.endTime) return ''
const [sh, sm] = form.startTime.split(':').map(Number) as [number, number]
const [eh, em] = form.endTime.split(':').map(Number) as [number, number]
const diff = (eh * 60 + em) - (sh * 60 + sm)
if (diff <= 0) return ''
const h = Math.floor(diff / 60)
const m = diff % 60
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
})
function toLocalDate(iso: string): string {
const d = new Date(iso)
const offset = d.getTimezoneOffset()
const local = new Date(d.getTime() - offset * 60000)
return local.toISOString().slice(0, 10)
}
function toLocalTime(iso: string): string {
const d = new Date(iso)
const offset = d.getTimezoneOffset()
const local = new Date(d.getTime() - offset * 60000)
return local.toISOString().slice(11, 16)
}
function toISO(date: string, time: string): string {
return new Date(`${date}T${time}`).toISOString()
}
function populateForm(entry: TimeEntry | null | undefined) {
if (entry) {
form.title = entry.title ?? ''
form.description = entry.description ?? ''
form.date = toLocalDate(entry.startedAt)
form.startTime = toLocalTime(entry.startedAt)
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
form.userId = entry.user?.id ?? authStore.user?.id ?? null
form.projectId = entry.project?.id ?? null
form.typeId = entry.types?.[0]?.id ?? null
} else {
form.title = ''
form.description = ''
form.date = props.prefillStartedAt ? toLocalDate(props.prefillStartedAt) : new Date().toISOString().slice(0, 10)
form.startTime = props.prefillStartedAt ? toLocalTime(props.prefillStartedAt) : ''
form.endTime = ''
form.userId = authStore.user?.id ?? null
form.projectId = null
form.typeId = null
}
}
watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
if (open) {
populateForm(entry)
}
})
async function onDelete() {
if (!props.entry) return
const { remove } = useTimeEntryService()
await remove(props.entry.id)
emit('saved')
isOpen.value = false
}
async function onSubmit() {
if (!form.date || !form.startTime || !form.endTime) return
const { create, update } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISO(form.date, form.startTime),
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
}
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as any)
}
emit('saved')
isOpen.value = false
}
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="space-y-2">
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
Aucune activité pour cette période
</div>
<div
v-for="entry in sortedEntries"
:key="entry.id"
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
@click="emit('editEntry', entry)"
>
<!-- Color bar -->
<div
class="h-10 w-1 shrink-0 rounded-full"
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/>
<!-- Main info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || 'Sans titre' }}
</span>
<span
v-for="type in entry.types"
:key="type.id"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: type.color }"
>
{{ type.label }}
</span>
</div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span v-if="entry.project">{{ entry.project.name }}</span>
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
</div>
</div>
<!-- Time info -->
<div class="shrink-0 text-right">
<div class="text-sm font-semibold tabular-nums text-neutral-900">
{{ formatDuration(entry) }}
</div>
<div class="text-xs tabular-nums text-neutral-400">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
</div>
<!-- Date -->
<div class="hidden shrink-0 text-xs text-neutral-400 sm:block">
{{ formatDate(entry.startedAt) }}
</div>
<!-- Delete action -->
<button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
title="Supprimer"
@click.stop="emit('deleteEntry', entry)"
>
<Icon name="mdi:delete-outline" size="18" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entries: TimeEntry[]
}>()
const emit = defineEmits<{
(e: 'editEntry', entry: TimeEntry): void
(e: 'deleteEntry', entry: TimeEntry): void
}>()
const sortedEntries = computed(() => {
return [...props.entries].sort((a, b) => {
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
})
})
function formatDuration(entry: TimeEntry): string {
const start = new Date(entry.startedAt).getTime()
const end = entry.stoppedAt ? new Date(entry.stoppedAt).getTime() : Date.now()
const diff = end - start
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
}
</script>

View File

@@ -0,0 +1,528 @@
<template>
<div ref="calendarEl" class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 280px);">
<!-- Day headers -->
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white">
<div class="w-16 shrink-0 border-r border-neutral-200" />
<div
v-for="day in days"
:key="day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center"
>
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div>
</div>
<!-- Grid body -->
<div ref="gridBodyEl" class="relative flex">
<!-- Hour labels -->
<div class="w-16 shrink-0">
<div
v-for="hour in hours"
:key="hour"
class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
:style="{ height: `${hourHeight}px` }"
>
{{ String(hour).padStart(2, '0') }} : 00
</div>
</div>
<!-- Day columns -->
<div
v-for="(day, dayIndex) in days"
:key="day.dateStr"
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
class="relative flex-1 border-r border-neutral-100"
@click="onClickGrid($event, day)"
@contextmenu.prevent="onContextMenuGrid($event, day)"
>
<!-- Hour row lines -->
<div
v-for="hour in hours"
:key="hour"
class="border-b border-neutral-100"
:style="{ height: `${hourHeight}px` }"
/>
<!-- Time entry blocks with overlap columns -->
<TimeEntryBlock
v-for="layout in layoutForDay(day.dateStr)"
:key="layout.entry.id"
:entry="layout.entry"
:hour-height="hourHeight"
:day-start-hour="0"
:is-drag-source="dragState?.entryId === layout.entry.id"
:column-index="layout.columnIndex"
:total-columns="layout.totalColumns"
@click="emit('editEntry', $event)"
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
@resize="(ent, newStart, newStop) => emit('resizeEntry', ent, newStart, newStop)"
@move-start="(payload) => onMoveStart(payload, dayIndex)"
/>
<!-- Overflow indicators for dense groups -->
<div
v-for="overflow in overflowsForDay(day.dateStr)"
:key="`overflow-${overflow.topPx}`"
class="absolute right-1 z-20 rounded bg-neutral-700 px-1.5 py-0.5 text-[10px] font-semibold text-white cursor-pointer hover:bg-neutral-600 transition"
:style="{ top: `${overflow.topPx}px` }"
@click.stop="openOverflowPopover(dayIndex, overflow)"
>
+{{ overflow.count }}
</div>
<!-- Overflow popover -->
<div
v-if="overflowPopover && overflowPopover.dayIndex === dayIndex"
class="absolute z-30 w-48 rounded-lg border border-neutral-200 bg-white p-2 shadow-xl"
:style="{ top: `${overflowPopover.topPx}px`, right: '4px' }"
>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-semibold text-neutral-600">{{ overflowPopover.entries.length }} entrées masquées</span>
<button class="text-neutral-400 hover:text-neutral-600 text-xs" @click="overflowPopover = null">&times;</button>
</div>
<div
v-for="entry in overflowPopover.entries"
:key="entry.id"
class="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-neutral-50 transition"
@click.stop="emit('editEntry', entry); overflowPopover = null"
>
<div
class="h-3 w-3 shrink-0 rounded-sm"
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/>
<div class="min-w-0">
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
<div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
</div>
</div>
</div>
<!-- Drag ghost preview -->
<div
v-if="dragState && dragState.targetDayIndex === dayIndex"
class="absolute left-1 right-1 rounded-md px-2 py-1 text-xs text-white shadow-lg pointer-events-none ring-2 ring-white/60 transition-[top] duration-75"
:style="{
top: `${dragState.ghostTopPx}px`,
height: `${dragState.ghostHeightPx}px`,
backgroundColor: dragState.color,
opacity: 0.75,
}"
>
<div class="font-semibold truncate">{{ dragState.title }}</div>
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
viewMode: 'week' | 'day'
}>()
const emit = defineEmits<{
(e: 'editEntry', entry: TimeEntry): void
(e: 'createEntry', startedAt: string): void
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'resizeEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
}>()
const hourHeight = 60
const hours = Array.from({ length: 24 }, (_, i) => i)
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
const calendarEl = ref<HTMLElement | null>(null)
const gridBodyEl = ref<HTMLElement | null>(null)
const dayColumnEls = ref<HTMLElement[]>([])
// Scroll to current hour on mount
onMounted(() => {
nextTick(() => {
if (!calendarEl.value) return
const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const scrollTarget = (currentMinutes / 60) * hourHeight - calendarEl.value.clientHeight / 3
calendarEl.value.scrollTop = Math.max(0, scrollTarget)
})
})
// --- Days computation ---
const days = computed(() => {
const count = props.viewMode === 'week' ? 7 : 1
const result = []
for (let i = 0; i < count; i++) {
const d = new Date(props.startDate)
d.setDate(d.getDate() + i)
const dateStr = toDateStr(d)
const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
const totalMs = dayEntries.reduce((sum, e) => {
if (!e.stoppedAt) return sum
return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
}, 0)
const totalH = Math.floor(totalMs / 3600000)
const totalM = Math.floor((totalMs % 3600000) / 60000)
const totalS = Math.floor((totalMs % 60000) / 1000)
result.push({
date: new Date(d),
dateStr,
dayNum: d.getDate(),
label: dayLabels[d.getDay()],
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
})
}
return result
})
function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function isToday(d: Date): boolean {
return toDateStr(d) === toDateStr(new Date())
}
function entriesForDay(dateStr: string): TimeEntry[] {
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
}
// --- Overlap layout computation ---
const MAX_VISIBLE_COLUMNS = 4
interface EntryLayout {
entry: TimeEntry
columnIndex: number
totalColumns: number
}
interface OverflowIndicator {
topPx: number
count: number
hiddenEntries: TimeEntry[]
}
function getEntryMinutes(entry: TimeEntry): { start: number; end: number } {
const s = new Date(entry.startedAt)
const startMin = s.getHours() * 60 + s.getMinutes()
const e = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
const endMin = e.getHours() * 60 + e.getMinutes()
return { start: startMin, end: Math.max(endMin, startMin + 15) }
}
function computeOverlapLayout(dayEntries: TimeEntry[]): { layouts: EntryLayout[]; overflows: OverflowIndicator[] } {
if (dayEntries.length === 0) return { layouts: [], overflows: [] }
// Sort by start time, then by duration (longest first)
const sorted = [...dayEntries].sort((a, b) => {
const aM = getEntryMinutes(a)
const bM = getEntryMinutes(b)
if (aM.start !== bM.start) return aM.start - bM.start
return (bM.end - bM.start) - (aM.end - aM.start)
})
// Group overlapping entries into clusters
const clusters: TimeEntry[][] = []
let currentCluster: TimeEntry[] = []
let clusterEnd = 0
for (const entry of sorted) {
const { start, end } = getEntryMinutes(entry)
if (currentCluster.length === 0 || start < clusterEnd) {
currentCluster.push(entry)
clusterEnd = Math.max(clusterEnd, end)
} else {
clusters.push(currentCluster)
currentCluster = [entry]
clusterEnd = end
}
}
if (currentCluster.length > 0) clusters.push(currentCluster)
const layouts: EntryLayout[] = []
const overflows: OverflowIndicator[] = []
for (const cluster of clusters) {
// Assign columns within this cluster
const colEnds: number[] = []
const clusterAssignments: { entry: TimeEntry; col: number }[] = []
for (const entry of cluster) {
const { start, end } = getEntryMinutes(entry)
// Find first column where this entry fits
let placed = false
for (let c = 0; c < colEnds.length; c++) {
if (colEnds[c]! <= start) {
colEnds[c] = end
clusterAssignments.push({ entry, col: c })
placed = true
break
}
}
if (!placed) {
clusterAssignments.push({ entry, col: colEnds.length })
colEnds.push(end)
}
}
const totalColumns = Math.min(colEnds.length, MAX_VISIBLE_COLUMNS)
let hasOverflow = false
for (const { entry, col } of clusterAssignments) {
if (col < MAX_VISIBLE_COLUMNS) {
layouts.push({
entry,
columnIndex: col,
totalColumns,
})
} else {
hasOverflow = true
}
}
if (hasOverflow) {
const hidden = clusterAssignments.filter((a) => a.col >= MAX_VISIBLE_COLUMNS)
const firstEntry = cluster[0]!
const { start } = getEntryMinutes(firstEntry)
overflows.push({
topPx: (start / 60) * hourHeight,
count: hidden.length,
hiddenEntries: hidden.map((a) => a.entry),
})
}
}
return { layouts, overflows }
}
const layoutCache = computed(() => {
const cache = new Map<string, { layouts: EntryLayout[]; overflows: OverflowIndicator[] }>()
for (const day of days.value) {
const dayEntries = entriesForDay(day.dateStr)
cache.set(day.dateStr, computeOverlapLayout(dayEntries))
}
return cache
})
function layoutForDay(dateStr: string): EntryLayout[] {
return layoutCache.value.get(dateStr)?.layouts ?? []
}
function overflowsForDay(dateStr: string): OverflowIndicator[] {
return layoutCache.value.get(dateStr)?.overflows ?? []
}
// --- Overflow popover ---
interface OverflowPopoverState {
dayIndex: number
topPx: number
entries: TimeEntry[]
}
const overflowPopover = ref<OverflowPopoverState | null>(null)
function openOverflowPopover(dayIndex: number, overflow: OverflowIndicator) {
overflowPopover.value = {
dayIndex,
topPx: overflow.topPx,
entries: overflow.hiddenEntries,
}
}
function formatTime(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function getSnappedMinutesFromY(y: number): number {
return Math.max(0, Math.min(23 * 60 + 45, Math.round((y / hourHeight) * 60 / 15) * 15))
}
function formatMinutes(totalMinutes: number): string {
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
// --- Click to create ---
let dragEndTime = 0
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
// Suppress click right after drag end
if (Date.now() - dragEndTime < 200) return
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const y = event.clientY - rect.top
const minutes = getSnappedMinutesFromY(y)
const h = Math.floor(minutes / 60)
const m = minutes % 60
const d = new Date(day.date)
d.setHours(h, m, 0, 0)
emit('createEntry', d.toISOString())
}
function onContextMenuGrid(event: MouseEvent, _day: { date: Date; dateStr: string }) {
emit('contextmenu', event, null)
}
// --- Drag to move ---
interface DragState {
entryId: number
entry: TimeEntry
title: string
color: string
durationMinutes: number
ghostHeightPx: number
offsetY: number
targetDayIndex: number
ghostTopPx: number
snappedMinutes: number
timeLabel: string
}
const dragState = ref<DragState | null>(null)
let autoScrollActive = false
let lastMouseEvent: MouseEvent | null = null
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
const entry = payload.entry
const startMinutes = new Date(entry.startedAt).getHours() * 60 + new Date(entry.startedAt).getMinutes()
const endMinutes = entry.stoppedAt
? new Date(entry.stoppedAt).getHours() * 60 + new Date(entry.stoppedAt).getMinutes()
: startMinutes + 60
const durationMinutes = endMinutes - startMinutes
dragState.value = {
entryId: entry.id,
entry,
title: entry.title || 'Sans titre',
color: entry.project?.color ?? '#94a3b8',
durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
offsetY: payload.offsetY,
targetDayIndex: sourceDayIndex,
ghostTopPx: (startMinutes / 60) * hourHeight,
snappedMinutes: startMinutes,
timeLabel: `${formatMinutes(startMinutes)} ${formatMinutes(endMinutes)}`,
}
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function updateDragPosition(event: MouseEvent) {
if (!dragState.value) return
// Find which column the cursor is over
let targetDayIndex = dragState.value.targetDayIndex
for (let i = 0; i < dayColumnEls.value.length; i++) {
const el = dayColumnEls.value[i]
if (!el) continue
const rect = el.getBoundingClientRect()
if (event.clientX >= rect.left && event.clientX <= rect.right) {
targetDayIndex = i
break
}
}
// Calculate Y position in the target column
const targetCol = dayColumnEls.value[targetDayIndex]
if (!targetCol) return
const colRect = targetCol.getBoundingClientRect()
const y = event.clientY - colRect.top - dragState.value.offsetY
const snappedMinutes = getSnappedMinutesFromY(y)
const endMinutes = snappedMinutes + dragState.value.durationMinutes
dragState.value.targetDayIndex = targetDayIndex
dragState.value.snappedMinutes = snappedMinutes
dragState.value.ghostTopPx = (snappedMinutes / 60) * hourHeight
dragState.value.timeLabel = `${formatMinutes(snappedMinutes)} ${formatMinutes(endMinutes)}`
}
function onDragMove(event: MouseEvent) {
if (!dragState.value) return
event.preventDefault()
lastMouseEvent = event
updateDragPosition(event)
// Start auto-scroll if not running
if (!autoScrollActive) {
autoScrollActive = true
requestAnimationFrame(autoScrollLoop)
}
}
function autoScrollLoop() {
if (!autoScrollActive || !lastMouseEvent || !calendarEl.value || !dragState.value) {
autoScrollActive = false
return
}
const rect = calendarEl.value.getBoundingClientRect()
const edgeSize = 60
const maxSpeed = 10
const distFromTop = lastMouseEvent.clientY - rect.top
const distFromBottom = rect.bottom - lastMouseEvent.clientY
let scrolled = false
if (distFromTop < edgeSize && distFromTop > 0) {
calendarEl.value.scrollTop -= maxSpeed * (1 - distFromTop / edgeSize)
scrolled = true
} else if (distFromBottom < edgeSize && distFromBottom > 0) {
calendarEl.value.scrollTop += maxSpeed * (1 - distFromBottom / edgeSize)
scrolled = true
}
// Update ghost position if we scrolled (scroll changes coordinate mapping)
if (scrolled && lastMouseEvent) {
updateDragPosition(lastMouseEvent)
}
requestAnimationFrame(autoScrollLoop)
}
function onDragEnd() {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
document.body.style.userSelect = ''
document.body.style.cursor = ''
autoScrollActive = false
lastMouseEvent = null
if (!dragState.value) return
const state = dragState.value
const targetDay = days.value[state.targetDayIndex]
if (targetDay) {
const h = Math.floor(state.snappedMinutes / 60)
const m = state.snappedMinutes % 60
const newStart = new Date(targetDay.date)
newStart.setHours(h, m, 0, 0)
const newStop = new Date(newStart.getTime() + state.durationMinutes * 60000)
emit('moveEntry', state.entry, newStart.toISOString(), newStop.toISOString())
}
dragState.value = null
dragEndTime = Date.now()
}
</script>