feat(time-tracking) : add calendar page, timer sidebar, and all UI components
- SidebarTimer widget with play/stop button - TimeEntryBlock with drag-to-move and resize - TimeEntryDrawer for create/edit entries - TimeEntryContextMenu for copy/paste/delete - TimeTrackingCalendar grid with week/day view - Time tracking page with filters and navigation - Sidebar link and timer integration in layout - TaskCard play button connected to timer store Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
115
frontend/components/TimeEntryBlock.vue
Normal file
115
frontend/components/TimeEntryBlock.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
:style="blockStyle"
|
||||
draggable="true"
|
||||
@click.stop="emit('click', entry)"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize"
|
||||
@mousedown.stop.prevent="onResizeStart"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: TimeEntry
|
||||
hourHeight: number
|
||||
dayStartHour: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', entry: TimeEntry): void
|
||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||
(e: 'resize', entry: TimeEntry, newStoppedAt: string): void
|
||||
(e: 'move', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): 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 duration = computed(() => {
|
||||
const diff = endDate.value.getTime() - startDate.value.getTime()
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const m = Math.floor((diff % 3600000) / 60000)
|
||||
const s = Math.floor((diff % 60000) / 1000)
|
||||
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ')
|
||||
})
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes()
|
||||
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes()
|
||||
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'
|
||||
|
||||
return {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx}px`,
|
||||
backgroundColor: bgColor,
|
||||
}
|
||||
})
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('application/time-entry-id', String(props.entry.id))
|
||||
event.dataTransfer!.setData('application/time-entry-start', props.entry.startedAt)
|
||||
event.dataTransfer!.setData('application/time-entry-stop', props.entry.stoppedAt ?? '')
|
||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent) {
|
||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||
}
|
||||
|
||||
function onResizeStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
const originalHeight = blockEl.value?.offsetHeight ?? 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
const newHeight = Math.max(originalHeight + delta, 20)
|
||||
if (blockEl.value) {
|
||||
blockEl.value.style.height = `${newHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
|
||||
const delta = e.clientY - startY
|
||||
const deltaMinutes = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
const originalEnd = endDate.value
|
||||
const newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000)
|
||||
emit('resize', props.entry, newEnd.toISOString())
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user