Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Pousse les filtres projet et tag a l'API (au lieu d'un filtrage client-side partiel sur la page courante) pour eviter les resultats incomplets en cas de pagination - Ajoute les watchers selectedProjectId/selectedTagId qui declenchent un reload - Mode liste : navigation et plage de chargement passent a 1 mois (au lieu d'une fenetre de 7 jours qui rendait le mode liste inutilisable) - Renomme l'option vide du filtre User en "Tous" (etait "User", ambigu) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
451 lines
14 KiB
Vue
451 lines
14 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>
|
|
<MalioButton
|
|
icon-name="mdi:plus"
|
|
icon-position="left"
|
|
button-class="shrink-0"
|
|
@click="openCreateDrawer()"
|
|
>
|
|
<span class="hidden sm:inline">Ajouter une Activité</span>
|
|
<span class="sm:hidden">Activité</span>
|
|
</MalioButton>
|
|
</div>
|
|
|
|
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
|
<div class="flex shrink-0 items-center gap-1 h-8">
|
|
<MalioButtonIcon
|
|
icon="mdi:chevron-left"
|
|
aria-label="Précédent"
|
|
variant="ghost"
|
|
@click="navigatePrev"
|
|
/>
|
|
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
|
{{ currentMonthLabel }}
|
|
</h2>
|
|
<MalioButtonIcon
|
|
icon="mdi:chevron-right"
|
|
aria-label="Suivant"
|
|
variant="ghost"
|
|
@click="navigateNext"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
|
<button
|
|
v-for="mode in (['week', 'day', 'list'] as const)"
|
|
:key="mode"
|
|
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
|
:class="viewMode === mode
|
|
? 'bg-primary-500 text-white shadow-sm'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="viewMode = mode"
|
|
>
|
|
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
|
</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="Tous"
|
|
/>
|
|
</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>
|
|
|
|
<MalioButton
|
|
:label="$t('timeEntries.export')"
|
|
variant="secondary"
|
|
icon-name="mdi:download"
|
|
icon-position="left"
|
|
button-class="w-auto px-4"
|
|
@click="exportDrawerOpen = true"
|
|
/>
|
|
</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"
|
|
/>
|
|
|
|
<TimeTrackingExportDrawer
|
|
v-model="exportDrawerOpen"
|
|
:users="users"
|
|
:projects="projects"
|
|
:tags="tags"
|
|
:clients="clients"
|
|
@export="onExport"
|
|
/>
|
|
</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 type { Client } from '~/services/dto/client'
|
|
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 clients = ref<Client[]>([])
|
|
const exportDrawerOpen = ref(false)
|
|
|
|
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(() => entries.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)
|
|
if (viewMode.value === 'day') {
|
|
d.setDate(d.getDate() - 1)
|
|
startDate.value = d
|
|
} else if (viewMode.value === 'list') {
|
|
d.setMonth(d.getMonth() - 1)
|
|
d.setDate(1)
|
|
d.setHours(0, 0, 0, 0)
|
|
startDate.value = d
|
|
} else {
|
|
d.setDate(d.getDate() - 7)
|
|
startDate.value = getMonday(d)
|
|
}
|
|
loadEntries()
|
|
}
|
|
|
|
function navigateNext() {
|
|
const d = new Date(startDate.value)
|
|
if (viewMode.value === 'day') {
|
|
d.setDate(d.getDate() + 1)
|
|
startDate.value = d
|
|
} else if (viewMode.value === 'list') {
|
|
d.setMonth(d.getMonth() + 1)
|
|
d.setDate(1)
|
|
d.setHours(0, 0, 0, 0)
|
|
startDate.value = d
|
|
} else {
|
|
d.setDate(d.getDate() + 7)
|
|
startDate.value = 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 onExport(params: {
|
|
after: string
|
|
before: string
|
|
users?: number[]
|
|
projects?: number[]
|
|
client?: number
|
|
tags?: number[]
|
|
}) {
|
|
const toast = useToast()
|
|
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
|
|
|
|
toast.info({ message: t('timeEntries.exportLoading') })
|
|
|
|
try {
|
|
const result = await timeEntryService.downloadExport(params)
|
|
|
|
const url = URL.createObjectURL(result.blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = result.filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
|
|
toast.success({ message: t('timeEntries.exportSuccess') })
|
|
} catch {
|
|
toast.error({ message: t('timeEntries.exportError') })
|
|
}
|
|
}
|
|
|
|
async function loadEntries() {
|
|
const end = new Date(startDate.value)
|
|
if (viewMode.value === 'day') {
|
|
end.setDate(end.getDate() + 1)
|
|
} else if (viewMode.value === 'list') {
|
|
end.setMonth(end.getMonth() + 1)
|
|
} else {
|
|
end.setDate(end.getDate() + 7)
|
|
}
|
|
|
|
entries.value = await timeEntryService.getByDateRange({
|
|
after: startDate.value.toISOString(),
|
|
before: end.toISOString(),
|
|
user: selectedUserId.value ?? undefined,
|
|
project: selectedProjectId.value ?? undefined,
|
|
tag: selectedTagId.value ?? undefined,
|
|
})
|
|
}
|
|
|
|
async function loadReferenceData() {
|
|
const api = useApi()
|
|
|
|
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
|
|
api.get<HydraCollection<UserData>>('/users'),
|
|
api.get<HydraCollection<Project>>('/projects'),
|
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
|
api.get<HydraCollection<Client>>('/clients'),
|
|
])
|
|
|
|
users.value = extractHydraMembers(usersData)
|
|
projects.value = extractHydraMembers(projectsData)
|
|
tags.value = extractHydraMembers(typesData)
|
|
clients.value = extractHydraMembers(clientsData)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
updatePageHeaderHeight()
|
|
|
|
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
|
|
pageHeaderResizeObserver = new ResizeObserver(() => {
|
|
updatePageHeaderHeight()
|
|
})
|
|
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
|
}
|
|
|
|
await loadReferenceData()
|
|
await loadEntries()
|
|
})
|
|
|
|
watch(viewMode, () => {
|
|
selectedDateFilter.value = null
|
|
if (viewMode.value === 'day') {
|
|
// keep current date
|
|
} else if (viewMode.value === 'list') {
|
|
const d = new Date(startDate.value)
|
|
d.setDate(1)
|
|
d.setHours(0, 0, 0, 0)
|
|
startDate.value = d
|
|
} else {
|
|
startDate.value = getMonday(startDate.value)
|
|
}
|
|
loadEntries()
|
|
})
|
|
|
|
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
|
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>
|