529 lines
19 KiB
Vue
529 lines
19 KiB
Vue
<template>
|
||
<div ref="calendarEl" class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 220px);">
|
||
<!-- 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">×</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>
|