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:
33
frontend/components/SidebarTimer.vue
Normal file
33
frontend/components/SidebarTimer.vue
Normal 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>
|
||||
@@ -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))
|
||||
|
||||
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>
|
||||
89
frontend/components/TimeEntryContextMenu.vue
Normal file
89
frontend/components/TimeEntryContextMenu.vue
Normal 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>
|
||||
177
frontend/components/TimeEntryDrawer.vue
Normal file
177
frontend/components/TimeEntryDrawer.vue
Normal 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>
|
||||
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>
|
||||
@@ -63,6 +63,12 @@
|
||||
label="Clients"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
icon="mdi:clock-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
@@ -71,6 +77,10 @@
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3 border-t border-secondary-500">
|
||||
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p v-if="!ui.sidebarCollapsed" class="font-bold">v {{ version }}</p>
|
||||
<button
|
||||
@@ -109,6 +119,12 @@ const currentProjectId = computed(() => {
|
||||
return match ? match[1] : null
|
||||
})
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
onMounted(() => {
|
||||
timerStore.fetchActive()
|
||||
})
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
@@ -17,10 +17,30 @@ export default defineNuxtConfig({
|
||||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
|
||||
}
|
||||
},
|
||||
devServer: {port: 3002},
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://nginx',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
settings: {
|
||||
timeout: 2000,
|
||||
|
||||
250
frontend/pages/time-tracking.vue
Normal file
250
frontend/pages/time-tracking.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">Suivi des temps</h1>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
+ Ajouter une Activité
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<h2 class="text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
<button
|
||||
v-for="mode in (['week', 'day'] as const)"
|
||||
:key="mode"
|
||||
class="px-3 py-1 text-sm font-semibold transition"
|
||||
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : 'Jour' }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-model="selectedUserId"
|
||||
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
|
||||
@change="loadEntries"
|
||||
>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
|
||||
@change="loadEntries"
|
||||
>
|
||||
<option :value="null">Type</option>
|
||||
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<TimeTrackingCalendar
|
||||
:entries="filteredEntries"
|
||||
:start-date="startDate"
|
||||
:view-mode="viewMode"
|
||||
@edit-entry="openEditDrawer"
|
||||
@create-entry="openCreateDrawer"
|
||||
@move-entry="onMoveEntry"
|
||||
@resize-entry="onResizeEntry"
|
||||
@contextmenu="onContextMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TimeEntryDrawer
|
||||
v-model="drawerOpen"
|
||||
:entry="editingEntry"
|
||||
:prefill-started-at="prefillStartedAt"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:types="types"
|
||||
@saved="loadEntries"
|
||||
/>
|
||||
|
||||
<TimeEntryContextMenu
|
||||
:visible="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
:entry="contextMenu.entry"
|
||||
:can-paste="!!clipboard"
|
||||
@close="contextMenu.visible = false"
|
||||
@copy="onCopy"
|
||||
@paste="onPaste"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</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'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
useHead({ title: 'Suivi des temps' })
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const timeEntryService = useTimeEntryService()
|
||||
|
||||
const viewMode = ref<'week' | 'day'>('week')
|
||||
const startDate = ref(getMonday(new Date()))
|
||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||
const selectedTypeId = ref<number | null>(null)
|
||||
|
||||
const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const types = ref<TaskType[]>([])
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editingEntry = ref<TimeEntry | null>(null)
|
||||
const prefillStartedAt = ref<string | null>(null)
|
||||
const clipboard = ref<TimeEntry | null>(null)
|
||||
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
entry: null as TimeEntry | null,
|
||||
targetDate: null as string | null,
|
||||
})
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
const d = startDate.value
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`
|
||||
})
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
if (!selectedTypeId.value) return entries.value
|
||||
return entries.value.filter((e) =>
|
||||
e.types.some((t) => t.id === selectedTypeId.value)
|
||||
)
|
||||
})
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
const day = date.getDay()
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||
date.setDate(diff)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() - (viewMode.value === 'week' ? 7 : 1))
|
||||
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() + (viewMode.value === 'week' ? 7 : 1))
|
||||
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function openCreateDrawer(startedAt?: string) {
|
||||
editingEntry.value = null
|
||||
prefillStartedAt.value = startedAt ?? null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(entry: TimeEntry) {
|
||||
editingEntry.value = entry
|
||||
prefillStartedAt.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function onResizeEntry(entry: TimeEntry, newStoppedAt: string) {
|
||||
await timeEntryService.update(entry.id, { stoppedAt: newStoppedAt })
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
||||
contextMenu.visible = true
|
||||
contextMenu.x = event.clientX
|
||||
contextMenu.y = event.clientY
|
||||
contextMenu.entry = entry
|
||||
}
|
||||
|
||||
function onCopy(entry: TimeEntry) {
|
||||
clipboard.value = entry
|
||||
}
|
||||
|
||||
async function onPaste() {
|
||||
if (!clipboard.value) return
|
||||
const { create } = useTimeEntryService()
|
||||
await create({
|
||||
title: clipboard.value.title ?? undefined,
|
||||
description: clipboard.value.description ?? undefined,
|
||||
startedAt: clipboard.value.startedAt,
|
||||
stoppedAt: clipboard.value.stoppedAt ?? undefined,
|
||||
user: `/api/users/${selectedUserId.value}`,
|
||||
project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
|
||||
types: clipboard.value.types.map((t) => `/api/task_types/${t.id}`),
|
||||
})
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function onDelete(entry: TimeEntry) {
|
||||
await timeEntryService.remove(entry.id)
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'week' ? 7 : 1))
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadReferenceData() {
|
||||
const api = useApi()
|
||||
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
api.get<any>('/users'),
|
||||
api.get<any>('/projects'),
|
||||
api.get<any>('/task_types'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
projects.value = extractHydraMembers(projectsData)
|
||||
types.value = extractHydraMembers(typesData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReferenceData()
|
||||
await loadEntries()
|
||||
})
|
||||
|
||||
watch(viewMode, () => {
|
||||
startDate.value = viewMode.value === 'week' ? getMonday(startDate.value) : startDate.value
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user