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:
176
frontend/components/TimeTrackingCalendar.vue
Normal file
176
frontend/components/TimeTrackingCalendar.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div 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 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 in days"
|
||||
:key="day.dateStr"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
@dragover.prevent
|
||||
@drop="onDropOnDay($event, day)"
|
||||
@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 -->
|
||||
<TimeEntryBlock
|
||||
v-for="entry in entriesForDay(day.dateStr)"
|
||||
:key="entry.id"
|
||||
:entry="entry"
|
||||
:hour-height="hourHeight"
|
||||
:day-start-hour="0"
|
||||
@click="emit('editEntry', $event)"
|
||||
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
||||
@resize="(ent, newStop) => emit('resizeEntry', ent, newStop)"
|
||||
/>
|
||||
</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, 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 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)
|
||||
}
|
||||
|
||||
function getHourFromY(event: MouseEvent, dayEl: HTMLElement): number {
|
||||
const rect = dayEl.getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const minutes = Math.round((y / hourHeight) * 60 / 15) * 15
|
||||
return minutes / 60
|
||||
}
|
||||
|
||||
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const hourDecimal = getHourFromY(event, target)
|
||||
const h = Math.floor(hourDecimal)
|
||||
const m = Math.round((hourDecimal - h) * 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)
|
||||
}
|
||||
|
||||
function onDropOnDay(event: DragEvent, day: { date: Date; dateStr: string }) {
|
||||
const entryId = event.dataTransfer?.getData('application/time-entry-id')
|
||||
const startStr = event.dataTransfer?.getData('application/time-entry-start')
|
||||
const stopStr = event.dataTransfer?.getData('application/time-entry-stop')
|
||||
|
||||
if (!entryId || !startStr) return
|
||||
|
||||
const entry = props.entries.find((e) => e.id === Number(entryId))
|
||||
if (!entry) return
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const hourDecimal = getHourFromY(event as unknown as MouseEvent, target)
|
||||
const h = Math.floor(hourDecimal)
|
||||
const m = Math.round((hourDecimal - h) * 60)
|
||||
|
||||
const originalStart = new Date(startStr)
|
||||
const originalStop = stopStr ? new Date(stopStr) : null
|
||||
const durationMs = originalStop ? originalStop.getTime() - originalStart.getTime() : 3600000
|
||||
|
||||
const newStart = new Date(day.date)
|
||||
newStart.setHours(h, m, 0, 0)
|
||||
const newStop = new Date(newStart.getTime() + durationMs)
|
||||
|
||||
emit('moveEntry', entry, newStart.toISOString(), newStop.toISOString())
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user