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:
2026-03-10 22:22:48 +01:00
parent cf021d6136
commit 74116506db
9 changed files with 885 additions and 3 deletions

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex items-center gap-2">
<button
class="flex items-center justify-center rounded-full transition-colors"
:class="timerStore.isRunning
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-green-500 hover:bg-green-600 text-white'"
style="width: 32px; height: 32px;"
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
>
<Icon
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
size="18"
/>
</button>
<span
v-if="!collapsed"
class="font-mono text-sm font-bold"
:class="timerStore.isRunning ? 'text-white' : 'text-neutral-400'"
>
{{ timerStore.elapsedFormatted }}
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
collapsed: boolean
}>()
const timerStore = useTimerStore()
</script>

View File

@@ -10,7 +10,7 @@
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<button
class="shrink-0 text-neutral-400 hover:text-primary-500"
@click.stop
@click.stop="onPlay"
>
<Icon name="mdi:play-circle-outline" size="20" />
</button>
@@ -60,6 +60,12 @@ const emit = defineEmits<{
(e: 'click'): void
}>()
const timerStore = useTimerStore()
function onPlay() {
timerStore.startFromTask(props.task)
}
function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(props.task.id))

View 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>

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,177 @@
<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 class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Heure début</label>
<input
v-model="form.startedAt"
type="datetime-local"
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">Heure fin</label>
<input
v-model="form.stoppedAt"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Utilisateur</label>
<select
v-model="form.userId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Projet</label>
<select
v-model="form.projectId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option :value="null"> Aucun </option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Type</label>
<select
v-model="form.typeId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option :value="null"> Aucun </option>
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
</select>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
</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: '',
startedAt: '',
stoppedAt: '',
userId: authStore.user?.id ?? null as number | null,
projectId: null as number | null,
typeId: null as number | null,
})
watch(() => props.entry, (entry) => {
if (entry) {
form.title = entry.title ?? ''
form.description = entry.description ?? ''
form.startedAt = toLocalDatetimeInput(entry.startedAt)
form.stoppedAt = entry.stoppedAt ? toLocalDatetimeInput(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.startedAt = props.prefillStartedAt ? toLocalDatetimeInput(props.prefillStartedAt) : ''
form.stoppedAt = ''
form.userId = authStore.user?.id ?? null
form.projectId = null
form.typeId = null
}
}, { immediate: true })
function toLocalDatetimeInput(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, 16)
}
function toISOFromLocal(localStr: string): string {
return new Date(localStr).toISOString()
}
async function onSubmit() {
const { create, update } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISOFromLocal(form.startedAt),
stoppedAt: form.stoppedAt ? toISOFromLocal(form.stoppedAt) : 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,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>