feat(time-tracking) : add list view for time entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
104
frontend/components/TimeEntryList.vue
Normal file
104
frontend/components/TimeEntryList.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
||||||
|
Aucune activité pour cette période
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="entry in sortedEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||||
|
@click="emit('editEntry', entry)"
|
||||||
|
>
|
||||||
|
<!-- Color bar -->
|
||||||
|
<div
|
||||||
|
class="h-10 w-1 shrink-0 rounded-full"
|
||||||
|
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||||
|
{{ entry.title || 'Sans titre' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="type in entry.types"
|
||||||
|
:key="type.id"
|
||||||
|
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: type.color }"
|
||||||
|
>
|
||||||
|
{{ type.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
|
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
||||||
|
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time info -->
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<div class="text-sm font-semibold tabular-nums text-neutral-900">
|
||||||
|
{{ formatDuration(entry) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs tabular-nums text-neutral-400">
|
||||||
|
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="hidden shrink-0 text-xs text-neutral-400 sm:block">
|
||||||
|
{{ formatDate(entry.startedAt) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete action -->
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||||
|
title="Supprimer"
|
||||||
|
@click.stop="emit('deleteEntry', entry)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entries: TimeEntry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'editEntry', entry: TimeEntry): void
|
||||||
|
(e: 'deleteEntry', entry: TimeEntry): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortedEntries = computed(() => {
|
||||||
|
return [...props.entries].sort((a, b) => {
|
||||||
|
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDuration(entry: TimeEntry): string {
|
||||||
|
const start = new Date(entry.startedAt).getTime()
|
||||||
|
const end = entry.stoppedAt ? new Date(entry.stoppedAt).getTime() : Date.now()
|
||||||
|
const diff = end - start
|
||||||
|
const h = Math.floor(diff / 3600000)
|
||||||
|
const m = Math.floor((diff % 3600000) / 60000)
|
||||||
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -20,13 +20,13 @@
|
|||||||
<Icon name="mdi:chevron-left" size="20" />
|
<Icon name="mdi:chevron-left" size="20" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="mode in (['week', 'day'] as const)"
|
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||||
:key="mode"
|
:key="mode"
|
||||||
class="px-3 py-1 text-sm font-semibold transition"
|
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'"
|
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
|
||||||
@click="viewMode = mode"
|
@click="viewMode = mode"
|
||||||
>
|
>
|
||||||
{{ mode === 'week' ? 'Semaine' : 'Jour' }}
|
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
|
||||||
<Icon name="mdi:chevron-right" size="20" />
|
<Icon name="mdi:chevron-right" size="20" />
|
||||||
@@ -59,7 +59,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
<TimeEntryList
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
|
:entries="filteredEntries"
|
||||||
|
@edit-entry="openEditDrawer"
|
||||||
|
@delete-entry="onDelete"
|
||||||
|
/>
|
||||||
<TimeTrackingCalendar
|
<TimeTrackingCalendar
|
||||||
|
v-else
|
||||||
:entries="filteredEntries"
|
:entries="filteredEntries"
|
||||||
:start-date="startDate"
|
:start-date="startDate"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
@@ -108,7 +115,7 @@ useHead({ title: 'Suivi des temps' })
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const timeEntryService = useTimeEntryService()
|
const timeEntryService = useTimeEntryService()
|
||||||
|
|
||||||
const viewMode = ref<'week' | 'day'>('week')
|
const viewMode = ref<'week' | 'day' | 'list'>('week')
|
||||||
const startDate = ref(getMonday(new Date()))
|
const startDate = ref(getMonday(new Date()))
|
||||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||||
const selectedTypeId = ref<number | null>(null)
|
const selectedTypeId = ref<number | null>(null)
|
||||||
@@ -160,15 +167,15 @@ function getMonday(d: Date): Date {
|
|||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() - (viewMode.value === 'week' ? 7 : 1))
|
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
||||||
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
|
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() + (viewMode.value === 'week' ? 7 : 1))
|
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||||
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
|
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +252,7 @@ async function onDelete(entry: TimeEntry) {
|
|||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
const end = new Date(startDate.value)
|
const end = new Date(startDate.value)
|
||||||
end.setDate(end.getDate() + (viewMode.value === 'week' ? 7 : 1))
|
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||||
|
|
||||||
entries.value = await timeEntryService.getByDateRange({
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
after: startDate.value.toISOString(),
|
after: startDate.value.toISOString(),
|
||||||
@@ -274,7 +281,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(viewMode, () => {
|
watch(viewMode, () => {
|
||||||
startDate.value = viewMode.value === 'week' ? getMonday(startDate.value) : startDate.value
|
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user