feat(time-tracking) : add TimeEntry DTO, service, timer store, and i18n keys
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,5 +63,10 @@
|
|||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès."
|
"deleted": "Utilisateur supprimé avec succès."
|
||||||
|
},
|
||||||
|
"timeEntries": {
|
||||||
|
"created": "Temps enregistré",
|
||||||
|
"updated": "Temps modifié",
|
||||||
|
"deleted": "Temps supprimé"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TaskPriority } from './task-priority'
|
|||||||
import type { TaskType } from './task-type'
|
import type { TaskType } from './task-type'
|
||||||
import type { TaskGroup } from './task-group'
|
import type { TaskGroup } from './task-group'
|
||||||
import type { UserData } from './user-data'
|
import type { UserData } from './user-data'
|
||||||
|
import type { Project } from './project'
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
id: number
|
id: number
|
||||||
@@ -15,6 +16,7 @@ export type Task = {
|
|||||||
priority: TaskPriority | null
|
priority: TaskPriority | null
|
||||||
assignee: UserData | null
|
assignee: UserData | null
|
||||||
group: TaskGroup | null
|
group: TaskGroup | null
|
||||||
|
project: Project | null
|
||||||
types: TaskType[]
|
types: TaskType[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
frontend/services/dto/time-entry.ts
Normal file
28
frontend/services/dto/time-entry.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { UserData } from './user-data'
|
||||||
|
import type { Project } from './project'
|
||||||
|
import type { Task } from './task'
|
||||||
|
import type { TaskType } from './task-type'
|
||||||
|
|
||||||
|
export type TimeEntry = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
title: string | null
|
||||||
|
description: string | null
|
||||||
|
startedAt: string
|
||||||
|
stoppedAt: string | null
|
||||||
|
user: UserData
|
||||||
|
project: Project | null
|
||||||
|
task: Task | null
|
||||||
|
types: TaskType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeEntryWrite = {
|
||||||
|
title?: string | null
|
||||||
|
description?: string | null
|
||||||
|
startedAt: string
|
||||||
|
stoppedAt?: string | null
|
||||||
|
user: string
|
||||||
|
project?: string | null
|
||||||
|
task?: string | null
|
||||||
|
types?: string[]
|
||||||
|
}
|
||||||
53
frontend/services/time-entries.ts
Normal file
53
frontend/services/time-entries.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { TimeEntry, TimeEntryWrite } from './dto/time-entry'
|
||||||
|
import type { HydraCollection } from '~/utils/api'
|
||||||
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
|
|
||||||
|
export function useTimeEntryService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getByDateRange(params: {
|
||||||
|
after: string
|
||||||
|
before: string
|
||||||
|
user?: number
|
||||||
|
types?: number[]
|
||||||
|
}): Promise<TimeEntry[]> {
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
'startedAt[after]': params.after,
|
||||||
|
'startedAt[before]': params.before,
|
||||||
|
}
|
||||||
|
if (params.user) {
|
||||||
|
query.user = `/api/users/${params.user}`
|
||||||
|
}
|
||||||
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActive(): Promise<TimeEntry | null> {
|
||||||
|
try {
|
||||||
|
const result = await api.get<TimeEntry | null>('/time_entries/active', {}, { toast: false })
|
||||||
|
return result ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
|
||||||
|
return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'timeEntries.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
|
||||||
|
return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'timeEntries.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/time_entries/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'timeEntries.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getByDateRange, getActive, create, update, remove }
|
||||||
|
}
|
||||||
109
frontend/stores/timer.ts
Normal file
109
frontend/stores/timer.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
|
||||||
|
export const useTimerStore = defineStore('timer', () => {
|
||||||
|
const activeEntry = ref<TimeEntry | null>(null)
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const isRunning = computed(() => activeEntry.value !== null)
|
||||||
|
|
||||||
|
const elapsed = computed(() => {
|
||||||
|
if (!activeEntry.value) return 0
|
||||||
|
const start = new Date(activeEntry.value.startedAt).getTime()
|
||||||
|
return Math.floor((now.value - start) / 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsedFormatted = computed(() => {
|
||||||
|
const total = elapsed.value
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const m = Math.floor((total % 3600) / 60)
|
||||||
|
const s = total % 60
|
||||||
|
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':')
|
||||||
|
})
|
||||||
|
|
||||||
|
function startTicking() {
|
||||||
|
stopTicking()
|
||||||
|
now.value = Date.now()
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTicking() {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActive() {
|
||||||
|
const { getActive } = useTimeEntryService()
|
||||||
|
activeEntry.value = await getActive()
|
||||||
|
if (activeEntry.value) {
|
||||||
|
startTicking()
|
||||||
|
} else {
|
||||||
|
stopTicking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.user) return
|
||||||
|
|
||||||
|
if (isRunning.value) {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { create } = useTimeEntryService()
|
||||||
|
activeEntry.value = await create({
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
user: `/api/users/${authStore.user.id}`,
|
||||||
|
})
|
||||||
|
startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFromTask(task: Task) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.user) return
|
||||||
|
|
||||||
|
if (isRunning.value) {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { create } = useTimeEntryService()
|
||||||
|
activeEntry.value = await create({
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
user: `/api/users/${authStore.user.id}`,
|
||||||
|
title: task.title,
|
||||||
|
project: task.project?.['@id'] ?? `/api/projects/${task.project?.id}`,
|
||||||
|
task: task['@id'] ?? `/api/tasks/${task.id}`,
|
||||||
|
types: task.types.map((t) => t['@id'] ?? `/api/task_types/${t.id}`),
|
||||||
|
})
|
||||||
|
startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!activeEntry.value) return
|
||||||
|
|
||||||
|
const { update } = useTimeEntryService()
|
||||||
|
await update(activeEntry.value.id, {
|
||||||
|
stoppedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
activeEntry.value = null
|
||||||
|
stopTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeEntry,
|
||||||
|
isRunning,
|
||||||
|
elapsed,
|
||||||
|
elapsedFormatted,
|
||||||
|
fetchActive,
|
||||||
|
start,
|
||||||
|
startFromTask,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user