feat(time-tracking) : add calendar overlap columns, responsive cards, project filter, and auto-scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="blockEl"
|
ref="blockEl"
|
||||||
class="absolute left-1 right-1 cursor-pointer overflow-hidden rounded-md px-2 py-1 text-xs text-white shadow-sm select-none"
|
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
||||||
:style="blockStyle"
|
:style="blockStyle"
|
||||||
draggable="true"
|
:class="{ 'opacity-40': isDragSource }"
|
||||||
@click.stop="emit('click', entry)"
|
|
||||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||||
@dragstart="onDragStart"
|
@mousedown="onMouseDown"
|
||||||
@dragend="onDragEnd"
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
<!-- Resize handle top (outside block) -->
|
||||||
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
<div
|
||||||
<div class="mt-0.5 flex items-center gap-1">
|
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
||||||
<span
|
style="bottom: 100%"
|
||||||
v-for="type in entry.types"
|
@mousedown.stop.prevent="onResizeTopStart"
|
||||||
:key="type.id"
|
>
|
||||||
class="rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
<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" />
|
||||||
:style="{ backgroundColor: type.color }"
|
|
||||||
>
|
|
||||||
{{ type.label }}
|
|
||||||
</span>
|
|
||||||
<span class="ml-auto text-[10px] opacity-80">{{ duration }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resize handle -->
|
<div class="px-1.5 py-0.5 h-full overflow-hidden">
|
||||||
|
<!-- Full display: title + project + types + duration -->
|
||||||
|
<template v-if="sizeLevel >= 3">
|
||||||
|
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
|
||||||
|
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
|
||||||
|
<div class="mt-0.5 flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
v-for="type in entry.types"
|
||||||
|
:key="type.id"
|
||||||
|
class="rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||||
|
:style="{ backgroundColor: type.color }"
|
||||||
|
>
|
||||||
|
{{ type.label }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto text-[10px] opacity-80">{{ duration }}</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] 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
|
<div
|
||||||
class="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize"
|
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
||||||
@mousedown.stop.prevent="onResizeStart"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -38,13 +67,16 @@ const props = defineProps<{
|
|||||||
entry: TimeEntry
|
entry: TimeEntry
|
||||||
hourHeight: number
|
hourHeight: number
|
||||||
dayStartHour: number
|
dayStartHour: number
|
||||||
|
isDragSource?: boolean
|
||||||
|
columnIndex?: number
|
||||||
|
totalColumns?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click', entry: TimeEntry): void
|
(e: 'click', entry: TimeEntry): void
|
||||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||||
(e: 'resize', entry: TimeEntry, newStoppedAt: string): void
|
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
(e: 'move', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const blockEl = ref<HTMLElement | null>(null)
|
const blockEl = ref<HTMLElement | null>(null)
|
||||||
@@ -52,61 +84,155 @@ const blockEl = ref<HTMLElement | null>(null)
|
|||||||
const startDate = computed(() => new Date(props.entry.startedAt))
|
const startDate = computed(() => new Date(props.entry.startedAt))
|
||||||
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
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 duration = computed(() => {
|
||||||
const diff = endDate.value.getTime() - startDate.value.getTime()
|
const startMs = startDate.value.getTime() + resizeTopDeltaMinutes.value * 60000
|
||||||
|
const endMs = endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
||||||
|
const diff = endMs - startMs
|
||||||
const h = Math.floor(diff / 3600000)
|
const h = Math.floor(diff / 3600000)
|
||||||
const m = Math.floor((diff % 3600000) / 60000)
|
const m = Math.floor((diff % 3600000) / 60000)
|
||||||
const s = Math.floor((diff % 60000) / 1000)
|
const s = Math.floor((diff % 60000) / 1000)
|
||||||
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ')
|
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 blockStyle = computed(() => {
|
||||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes()
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||||
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes()
|
|
||||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||||
const heightPx = Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
|
||||||
const bgColor = props.entry.project?.color ?? '#94a3b8'
|
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 {
|
return {
|
||||||
top: `${topPx}px`,
|
top: `${topPx}px`,
|
||||||
height: `${heightPx}px`,
|
height: `${heightPx.value}px`,
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
|
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||||
|
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function onDragStart(event: DragEvent) {
|
// --- Click / Drag detection ---
|
||||||
event.dataTransfer!.effectAllowed = 'move'
|
let mouseDownPos = { x: 0, y: 0 }
|
||||||
event.dataTransfer!.setData('application/time-entry-id', String(props.entry.id))
|
let mouseDownHandled = false
|
||||||
event.dataTransfer!.setData('application/time-entry-start', props.entry.startedAt)
|
|
||||||
event.dataTransfer!.setData('application/time-entry-stop', props.entry.stoppedAt ?? '')
|
function onMouseDown(event: MouseEvent) {
|
||||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
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 onDragEnd(event: DragEvent) {
|
function onMouseMoveDetect(event: MouseEvent) {
|
||||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
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 onResizeStart(event: MouseEvent) {
|
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
|
const startY = event.clientY
|
||||||
const originalHeight = blockEl.value?.offsetHeight ?? 0
|
resizeBottomDeltaMinutes.value = 0
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 's-resize'
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
const delta = e.clientY - startY
|
const delta = e.clientY - startY
|
||||||
const newHeight = Math.max(originalHeight + delta, 20)
|
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||||
if (blockEl.value) {
|
}
|
||||||
blockEl.value.style.height = `${newHeight}px`
|
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp(e: MouseEvent) {
|
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('mousemove', onMouseMove)
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
|
||||||
const delta = e.clientY - startY
|
const finalDelta = resizeTopDeltaMinutes.value
|
||||||
const deltaMinutes = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
resizeTopDeltaMinutes.value = 0
|
||||||
const originalEnd = endDate.value
|
|
||||||
const newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000)
|
if (finalDelta !== 0) {
|
||||||
emit('resize', props.entry, newEnd.toISOString())
|
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('mousemove', onMouseMove)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 220px);">
|
<div ref="calendarEl" class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 220px);">
|
||||||
<!-- Day headers -->
|
<!-- Day headers -->
|
||||||
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white">
|
<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 class="w-16 shrink-0 border-r border-neutral-200" />
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grid body -->
|
<!-- Grid body -->
|
||||||
<div class="relative flex">
|
<div ref="gridBodyEl" class="relative flex">
|
||||||
<!-- Hour labels -->
|
<!-- Hour labels -->
|
||||||
<div class="w-16 shrink-0">
|
<div class="w-16 shrink-0">
|
||||||
<div
|
<div
|
||||||
@@ -34,11 +34,10 @@
|
|||||||
|
|
||||||
<!-- Day columns -->
|
<!-- Day columns -->
|
||||||
<div
|
<div
|
||||||
v-for="day in days"
|
v-for="(day, dayIndex) in days"
|
||||||
:key="day.dateStr"
|
:key="day.dateStr"
|
||||||
|
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||||
class="relative flex-1 border-r border-neutral-100"
|
class="relative flex-1 border-r border-neutral-100"
|
||||||
@dragover.prevent
|
|
||||||
@drop="onDropOnDay($event, day)"
|
|
||||||
@click="onClickGrid($event, day)"
|
@click="onClickGrid($event, day)"
|
||||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||||
>
|
>
|
||||||
@@ -50,17 +49,76 @@
|
|||||||
:style="{ height: `${hourHeight}px` }"
|
:style="{ height: `${hourHeight}px` }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Time entry blocks -->
|
<!-- Time entry blocks with overlap columns -->
|
||||||
<TimeEntryBlock
|
<TimeEntryBlock
|
||||||
v-for="entry in entriesForDay(day.dateStr)"
|
v-for="layout in layoutForDay(day.dateStr)"
|
||||||
:key="entry.id"
|
:key="layout.entry.id"
|
||||||
:entry="entry"
|
:entry="layout.entry"
|
||||||
:hour-height="hourHeight"
|
:hour-height="hourHeight"
|
||||||
:day-start-hour="0"
|
:day-start-hour="0"
|
||||||
|
:is-drag-source="dragState?.entryId === layout.entry.id"
|
||||||
|
:column-index="layout.columnIndex"
|
||||||
|
:total-columns="layout.totalColumns"
|
||||||
@click="emit('editEntry', $event)"
|
@click="emit('editEntry', $event)"
|
||||||
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
||||||
@resize="(ent, newStop) => emit('resizeEntry', ent, newStop)"
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,15 +137,30 @@ const emit = defineEmits<{
|
|||||||
(e: 'editEntry', entry: TimeEntry): void
|
(e: 'editEntry', entry: TimeEntry): void
|
||||||
(e: 'createEntry', startedAt: string): void
|
(e: 'createEntry', startedAt: string): void
|
||||||
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
(e: 'resizeEntry', entry: TimeEntry, newStoppedAt: string): void
|
(e: 'resizeEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const hourHeight = 60
|
const hourHeight = 60
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
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 days = computed(() => {
|
||||||
const count = props.viewMode === 'week' ? 7 : 1
|
const count = props.viewMode === 'week' ? 7 : 1
|
||||||
const result = []
|
const result = []
|
||||||
@@ -127,50 +200,329 @@ function entriesForDay(dateStr: string): TimeEntry[] {
|
|||||||
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHourFromY(event: MouseEvent, dayEl: HTMLElement): number {
|
// --- Overlap layout computation ---
|
||||||
const rect = dayEl.getBoundingClientRect()
|
const MAX_VISIBLE_COLUMNS = 4
|
||||||
const y = event.clientY - rect.top
|
|
||||||
const minutes = Math.round((y / hourHeight) * 60 / 15) * 15
|
interface EntryLayout {
|
||||||
return minutes / 60
|
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 }) {
|
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 target = event.currentTarget as HTMLElement
|
||||||
const hourDecimal = getHourFromY(event, target)
|
const rect = target.getBoundingClientRect()
|
||||||
const h = Math.floor(hourDecimal)
|
const y = event.clientY - rect.top
|
||||||
const m = Math.round((hourDecimal - h) * 60)
|
const minutes = getSnappedMinutesFromY(y)
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
const d = new Date(day.date)
|
const d = new Date(day.date)
|
||||||
d.setHours(h, m, 0, 0)
|
d.setHours(h, m, 0, 0)
|
||||||
emit('createEntry', d.toISOString())
|
emit('createEntry', d.toISOString())
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContextMenuGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
|
function onContextMenuGrid(event: MouseEvent, _day: { date: Date; dateStr: string }) {
|
||||||
emit('contextmenu', event, null)
|
emit('contextmenu', event, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDropOnDay(event: DragEvent, day: { date: Date; dateStr: string }) {
|
// --- Drag to move ---
|
||||||
const entryId = event.dataTransfer?.getData('application/time-entry-id')
|
interface DragState {
|
||||||
const startStr = event.dataTransfer?.getData('application/time-entry-start')
|
entryId: number
|
||||||
const stopStr = event.dataTransfer?.getData('application/time-entry-stop')
|
entry: TimeEntry
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
durationMinutes: number
|
||||||
|
ghostHeightPx: number
|
||||||
|
offsetY: number
|
||||||
|
targetDayIndex: number
|
||||||
|
ghostTopPx: number
|
||||||
|
snappedMinutes: number
|
||||||
|
timeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
if (!entryId || !startStr) return
|
const dragState = ref<DragState | null>(null)
|
||||||
|
let autoScrollActive = false
|
||||||
|
let lastMouseEvent: MouseEvent | null = null
|
||||||
|
|
||||||
const entry = props.entries.find((e) => e.id === Number(entryId))
|
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
|
||||||
if (!entry) return
|
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
|
||||||
|
|
||||||
const target = event.currentTarget as HTMLElement
|
dragState.value = {
|
||||||
const hourDecimal = getHourFromY(event as unknown as MouseEvent, target)
|
entryId: entry.id,
|
||||||
const h = Math.floor(hourDecimal)
|
entry,
|
||||||
const m = Math.round((hourDecimal - h) * 60)
|
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)}`,
|
||||||
|
}
|
||||||
|
|
||||||
const originalStart = new Date(startStr)
|
document.body.style.userSelect = 'none'
|
||||||
const originalStop = stopStr ? new Date(stopStr) : null
|
document.body.style.cursor = 'grabbing'
|
||||||
const durationMs = originalStop ? originalStop.getTime() - originalStart.getTime() : 3600000
|
document.addEventListener('mousemove', onDragMove)
|
||||||
|
document.addEventListener('mouseup', onDragEnd)
|
||||||
|
}
|
||||||
|
|
||||||
const newStart = new Date(day.date)
|
function updateDragPosition(event: MouseEvent) {
|
||||||
newStart.setHours(h, m, 0, 0)
|
if (!dragState.value) return
|
||||||
const newStop = new Date(newStart.getTime() + durationMs)
|
|
||||||
|
|
||||||
emit('moveEntry', entry, newStart.toISOString(), newStop.toISOString())
|
// 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>
|
</script>
|
||||||
|
|||||||
@@ -41,10 +41,17 @@
|
|||||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
|
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option :value="null">Projet</option>
|
||||||
|
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
v-model="selectedTypeId"
|
v-model="selectedTypeId"
|
||||||
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
|
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
|
||||||
@change="loadEntries"
|
|
||||||
>
|
>
|
||||||
<option :value="null">Type</option>
|
<option :value="null">Type</option>
|
||||||
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
|
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
|
||||||
@@ -105,6 +112,7 @@ const viewMode = ref<'week' | 'day'>('week')
|
|||||||
const startDate = ref(getMonday(new Date()))
|
const startDate = ref(getMonday(new Date()))
|
||||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||||
const selectedTypeId = ref<number | null>(null)
|
const selectedTypeId = ref<number | null>(null)
|
||||||
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
|
||||||
const entries = ref<TimeEntry[]>([])
|
const entries = ref<TimeEntry[]>([])
|
||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
@@ -131,10 +139,14 @@ const currentMonthLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => {
|
||||||
if (!selectedTypeId.value) return entries.value
|
let result = entries.value
|
||||||
return entries.value.filter((e) =>
|
if (selectedProjectId.value) {
|
||||||
e.types.some((t) => t.id === selectedTypeId.value)
|
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||||
)
|
}
|
||||||
|
if (selectedTypeId.value) {
|
||||||
|
result = result.filter((e) => e.types.some((t) => t.id === selectedTypeId.value))
|
||||||
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
@@ -173,13 +185,31 @@ function openEditDrawer(entry: TimeEntry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
// Optimistic update — instant visual feedback
|
||||||
await loadEntries()
|
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const original = entries.value[idx]!
|
||||||
|
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||||
|
} catch {
|
||||||
|
entries.value[idx] = original
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onResizeEntry(entry: TimeEntry, newStoppedAt: string) {
|
async function onResizeEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||||
await timeEntryService.update(entry.id, { stoppedAt: newStoppedAt })
|
// Optimistic update — instant visual feedback
|
||||||
await loadEntries()
|
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const original = entries.value[idx]!
|
||||||
|
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||||
|
} catch {
|
||||||
|
entries.value[idx] = original
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user