Files
Lesstime/frontend/pages/time-tracking.vue
Matthieu 5d378c1f75 refactor(frontend) : replace any types with concrete TypeScript types
Replace 9 occurrences of 'any' with proper types: HydraCollection, Task,
ClientTicketWrite, TimeEntryWrite across 7 components.

Ticket: T-023

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00

357 lines
12 KiB
Vue

<template>
<div class="flex min-h-0 flex-1 flex-col">
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-primary-600 transition sm:px-4 sm:text-sm"
@click="openCreateDrawer()"
>
<span class="hidden sm:inline">+ Ajouter une Activité</span>
<span class="sm:hidden">+ Activité</span>
</button>
</div>
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
{{ currentMonthLabel }}
</h2>
<div class="flex shrink-0 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', 'list'] 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' : mode === 'day' ? 'Jour' : 'Liste' }}
</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>
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
label="User"
empty-option-label="User"
/>
</div>
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
empty-option-label="Tous"
label="Projet"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
empty-option-label="Tous"
label="Tag"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<DateFilter v-model="selectedDateFilter" />
</div>
</div>
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
<TimeEntryList
v-if="viewMode === 'list'"
:entries="filteredEntries"
@edit-entry="openEditDrawer"
@delete-entry="onDelete"
/>
<TimeTrackingCalendar
v-else
:entries="filteredEntries"
:start-date="startDate"
:view-mode="viewMode"
:sticky-offset="pageHeaderHeight"
@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"
:tags="tags"
@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 { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' })
const authStore = useAuthStore()
const timeEntryService = useTimeEntryService()
const viewMode = ref<'week' | 'day' | 'list'>('week')
const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTagId = ref<number | null>(null)
const selectedProjectId = ref<number | null>(null)
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const tags = ref<TaskTag[]>([])
const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
const prefillStartedAt = ref<string | null>(null)
const clipboard = ref<TimeEntry | null>(null)
const pageHeaderEl = ref<HTMLElement | null>(null)
const pageHeaderHeight = ref(0)
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 userOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const tagOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
let pageHeaderResizeObserver: ResizeObserver | null = null
function updatePageHeaderHeight() {
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
}
const filteredEntries = computed(() => {
let result = entries.value
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
return result
})
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 === 'day' ? 1 : 7))
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
loadEntries()
}
function navigateNext() {
const d = new Date(startDate.value)
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
startDate.value = viewMode.value === 'day' ? d : getMonday(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) {
// Optimistic update — instant visual feedback
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, newStartedAt: string, newStoppedAt: string) {
// Optimistic update — instant visual feedback
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) {
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,
tags: clipboard.value.tags.map((t) => `/api/task_tags/${t.id}`),
})
await loadEntries()
}
onBeforeUnmount(() => {
pageHeaderResizeObserver?.disconnect()
})
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 === 'day' ? 1 : 7))
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<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
users.value = extractHydraMembers(usersData)
projects.value = extractHydraMembers(projectsData)
tags.value = extractHydraMembers(typesData)
}
onMounted(async () => {
updatePageHeaderHeight()
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
pageHeaderResizeObserver = new ResizeObserver(() => {
updatePageHeaderHeight()
})
pageHeaderResizeObserver.observe(pageHeaderEl.value)
}
await loadReferenceData()
await loadEntries()
})
watch(viewMode, () => {
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
loadEntries()
})
watch(selectedUserId, () => {
loadEntries()
})
watch(selectedDateFilter, (val) => {
if (!val) return
if (Array.isArray(val)) {
startDate.value = getMonday(val[0])
viewMode.value = 'week'
} else {
startDate.value = val
viewMode.value = 'day'
}
loadEntries()
})
</script>