feat(time-tracking) : extract time-tracking front into Nuxt module layer
Companion to the backend module migration (LST-64). The Nuxt layer is auto-detected from frontend/modules/* — no nuxt.config change needed. - Move page, timer store, time-entries service + DTO and the 6 time-tracking components into frontend/modules/time-tracking/. - Rewrite explicit service/DTO imports to ~/modules/time-tracking/* (store and components stay auto-imported); update the dashboard (index.vue) consumer. - Route /time-tracking preserved; i18n keys kept in the global locale file. nuxt build passes; /time-tracking routed.
This commit is contained in:
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="blockEl"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||
:style="blockStyle"
|
||||
:class="{ 'opacity-40': isDragSource }"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@mousedown="onMouseDown"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Resize handle top (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
||||
style="bottom: 100%"
|
||||
@mousedown.stop.prevent="onResizeTopStart"
|
||||
>
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
||||
<!-- Top: title + project -->
|
||||
<div class="min-w-0">
|
||||
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom: tags left, duration right -->
|
||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle bottom (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
||||
style="top: 100%"
|
||||
@mousedown.stop.prevent="onResizeBottomStart"
|
||||
>
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: TimeEntry
|
||||
hourHeight: number
|
||||
dayStartHour: number
|
||||
isDragSource?: boolean
|
||||
columnIndex?: number
|
||||
totalColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', entry: TimeEntry): void
|
||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): 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 resizeTopDeltaMinutes = ref(0)
|
||||
const resizeBottomDeltaMinutes = ref(0)
|
||||
|
||||
const duration = computed(() => {
|
||||
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
||||
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
const heightPx = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
||||
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
||||
})
|
||||
|
||||
// Responsive content levels based on block height
|
||||
// 3 = full (title + project + types + duration)
|
||||
// 2 = medium (title + duration)
|
||||
// 1 = small (title only)
|
||||
// 0 = tiny (colored bar only)
|
||||
const sizeLevel = computed(() => {
|
||||
const h = heightPx.value
|
||||
if (h >= 50) return 3
|
||||
if (h >= 35) return 2
|
||||
if (h >= 20) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||
|
||||
const col = props.columnIndex ?? 0
|
||||
const total = props.totalColumns ?? 1
|
||||
const gapPx = 2
|
||||
const leftPercent = (col / total) * 100
|
||||
const widthPercent = (1 / total) * 100
|
||||
|
||||
const base: Record<string, string> = {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx.value}px`,
|
||||
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||
}
|
||||
|
||||
if (hasProject.value) {
|
||||
const hex = props.entry.project!.color.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
||||
base.color = `rgb(${r}, ${g}, ${b})`
|
||||
} else {
|
||||
base.backgroundColor = '#e5e7eb'
|
||||
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
||||
base.backgroundSize = '12px 12px'
|
||||
base.color = '#6b7280'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Click / Drag detection ---
|
||||
let mouseDownPos = { x: 0, y: 0 }
|
||||
let mouseDownHandled = false
|
||||
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.button !== 0) return
|
||||
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
||||
|
||||
mouseDownPos = { x: event.clientX, y: event.clientY }
|
||||
mouseDownHandled = false
|
||||
|
||||
document.addEventListener('mousemove', onMouseMoveDetect)
|
||||
document.addEventListener('mouseup', onMouseUpDetect)
|
||||
}
|
||||
|
||||
function onMouseMoveDetect(event: MouseEvent) {
|
||||
const dx = event.clientX - mouseDownPos.x
|
||||
const dy = event.clientY - mouseDownPos.y
|
||||
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
||||
mouseDownHandled = true
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
|
||||
const rect = blockEl.value!.getBoundingClientRect()
|
||||
emit('moveStart', {
|
||||
entry: props.entry,
|
||||
offsetY: mouseDownPos.y - rect.top,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUpDetect() {
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
if (!mouseDownHandled) {
|
||||
emit('click', props.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resize bottom (change stoppedAt) ---
|
||||
function onResizeBottomStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 's-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeBottomDeltaMinutes.value
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// --- Resize top (change startedAt) ---
|
||||
function onResizeTopStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'n-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeTopDeltaMinutes.value
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
</script>
|
||||
@@ -1,89 +0,0 @@
|
||||
<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>
|
||||
@@ -1,293 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
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">Début</label>
|
||||
<input
|
||||
v-model="form.startTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Fin</label>
|
||||
<input
|
||||
v-model="form.endTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="durationLabel"
|
||||
class="rounded-md bg-neutral-100 px-3 py-2 text-center text-sm font-semibold text-neutral-600 tabular-nums"
|
||||
>
|
||||
{{ durationLabel }}
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.projectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="secondary"
|
||||
label="Dupliquer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDuplicate"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry, TimeEntryWrite } 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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
entry?: TimeEntry | null
|
||||
prefillStartedAt?: string | null
|
||||
users: UserData[]
|
||||
projects: Project[]
|
||||
tags: TaskTag[]
|
||||
}>()
|
||||
|
||||
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: '',
|
||||
date: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
userId: authStore.user?.id ?? null as number | null,
|
||||
projectId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
props.projects.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (!form.startTime || !form.endTime) return ''
|
||||
const [sh, sm] = form.startTime.split(':').map(Number) as [number, number]
|
||||
const [eh, em] = form.endTime.split(':').map(Number) as [number, number]
|
||||
const diff = (eh * 60 + em) - (sh * 60 + sm)
|
||||
if (diff <= 0) return ''
|
||||
const h = Math.floor(diff / 60)
|
||||
const m = diff % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalDate(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, 10)
|
||||
}
|
||||
|
||||
function toLocalTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(11, 16)
|
||||
}
|
||||
|
||||
function toISO(date: string, time: string): string {
|
||||
return new Date(`${date}T${time}`).toISOString()
|
||||
}
|
||||
|
||||
function populateForm(entry: TimeEntry | null | undefined) {
|
||||
if (entry) {
|
||||
form.title = entry.title ?? ''
|
||||
form.description = entry.description ?? ''
|
||||
form.date = toLocalDate(entry.startedAt)
|
||||
form.startTime = toLocalTime(entry.startedAt)
|
||||
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
|
||||
form.userId = entry.user?.id ?? authStore.user?.id ?? null
|
||||
form.projectId = entry.project?.id ?? null
|
||||
form.tagIds = entry.tags?.map(t => t.id) ?? []
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.date = props.prefillStartedAt ? toLocalDate(props.prefillStartedAt) : new Date().toISOString().slice(0, 10)
|
||||
form.startTime = props.prefillStartedAt ? toLocalTime(props.prefillStartedAt) : ''
|
||||
form.endTime = ''
|
||||
form.userId = authStore.user?.id ?? null
|
||||
form.projectId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
if (open) {
|
||||
populateForm(entry)
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
await remove(props.entry.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create, update } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.entry) {
|
||||
await update(props.entry.id, payload)
|
||||
} else {
|
||||
await create(payload as TimeEntryWrite)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,106 +0,0 @@
|
||||
<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">
|
||||
{{ $t('timeEntries.noEntries') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm: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="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.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 && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(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 -->
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="shrink-0 text-neutral-300 opacity-0 hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
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>
|
||||
@@ -1,608 +0,0 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Grid body with sticky header -->
|
||||
<div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Day headers (sticky inside scroll container) -->
|
||||
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
>
|
||||
<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
|
||||
v-if="day.holiday"
|
||||
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
|
||||
:title="day.holiday"
|
||||
>
|
||||
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ day.holiday }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<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, dayIndex) in days"
|
||||
:key="day.dateStr"
|
||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
@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 with overlap columns -->
|
||||
<TimeEntryBlock
|
||||
v-for="layout in layoutForDay(day.dateStr)"
|
||||
:key="layout.entry.id"
|
||||
:entry="layout.entry"
|
||||
:hour-height="hourHeight"
|
||||
:day-start-hour="0"
|
||||
:is-drag-source="dragState?.entryId === layout.entry.id"
|
||||
:column-index="layout.columnIndex"
|
||||
:total-columns="layout.totalColumns"
|
||||
@click="emit('editEntry', $event)"
|
||||
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
||||
@resize="(ent, newStart, newStop) => emit('resizeEntry', ent, newStart, newStop)"
|
||||
@move-start="(payload) => onMoveStart(payload, dayIndex)"
|
||||
/>
|
||||
|
||||
<!-- Overflow indicators for dense groups -->
|
||||
<div
|
||||
v-for="overflow in overflowsForDay(day.dateStr)"
|
||||
:key="`overflow-${overflow.topPx}`"
|
||||
class="absolute right-1 z-20 rounded bg-neutral-700 px-1.5 py-0.5 text-[10px] font-semibold text-white cursor-pointer hover:bg-neutral-600 transition"
|
||||
:style="{ top: `${overflow.topPx}px` }"
|
||||
@click.stop="openOverflowPopover(dayIndex, overflow)"
|
||||
>
|
||||
+{{ overflow.count }}
|
||||
</div>
|
||||
|
||||
<!-- Overflow popover -->
|
||||
<div
|
||||
v-if="overflowPopover && overflowPopover.dayIndex === dayIndex"
|
||||
class="absolute z-30 w-48 rounded-lg border border-neutral-200 bg-white p-2 shadow-xl"
|
||||
:style="{ top: `${overflowPopover.topPx}px`, right: '4px' }"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-neutral-600">{{ overflowPopover.entries.length }} entrées masquées</span>
|
||||
<button class="text-neutral-400 hover:text-neutral-600 text-xs" @click="overflowPopover = null">×</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in overflowPopover.entries"
|
||||
:key="entry.id"
|
||||
class="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-neutral-50 transition"
|
||||
@click.stop="emit('editEntry', entry); overflowPopover = null"
|
||||
>
|
||||
<div
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div class="text-[10px] text-neutral-500">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current time indicator -->
|
||||
<div
|
||||
v-if="isToday(day.date)"
|
||||
class="absolute left-0 right-0 z-10 pointer-events-none"
|
||||
:style="{ top: `${currentTimeTopPx}px` }"
|
||||
>
|
||||
<div class="relative flex items-center">
|
||||
<div class="absolute -left-[5px] h-[10px] w-[10px] rounded-full bg-orange-500" />
|
||||
<div class="h-[2px] w-full bg-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag ghost preview -->
|
||||
<div
|
||||
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
||||
class="absolute left-1 right-1 rounded-md px-2 py-1 text-xs text-white shadow-lg pointer-events-none ring-2 ring-white/60 transition-[top] duration-75"
|
||||
:style="{
|
||||
top: `${dragState.ghostTopPx}px`,
|
||||
height: `${dragState.ghostHeightPx}px`,
|
||||
backgroundColor: dragState.color,
|
||||
opacity: 0.75,
|
||||
}"
|
||||
>
|
||||
<div class="font-semibold truncate">{{ dragState.title }}</div>
|
||||
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end columns flex -->
|
||||
</div><!-- end gridBodyEl -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
startDate: Date
|
||||
viewMode: 'week' | 'day'
|
||||
stickyOffset?: number
|
||||
}>()
|
||||
|
||||
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, newStartedAt: string, 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 calendarEl = ref<HTMLElement | null>(null)
|
||||
const gridBodyEl = ref<HTMLElement | null>(null)
|
||||
const dayColumnEls = ref<HTMLElement[]>([])
|
||||
const stickyOffset = computed(() => props.stickyOffset ?? 0)
|
||||
|
||||
// --- Current time indicator ---
|
||||
const nowMinutes = ref(0)
|
||||
let nowTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function updateNowMinutes() {
|
||||
const now = new Date()
|
||||
nowMinutes.value = now.getHours() * 60 + now.getMinutes()
|
||||
}
|
||||
|
||||
const currentTimeTopPx = computed(() => (nowMinutes.value / 60) * hourHeight)
|
||||
|
||||
updateNowMinutes()
|
||||
|
||||
onMounted(() => {
|
||||
nowTimer = setInterval(updateNowMinutes, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(nowTimer)
|
||||
})
|
||||
|
||||
function getScrollParent(): HTMLElement | null {
|
||||
let el = calendarEl.value?.parentElement
|
||||
while (el) {
|
||||
if (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'visible') return el
|
||||
el = el.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Scroll to current hour on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!gridBodyEl.value) return
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Public holidays (computed server-side, shared with the absence calendar) ---
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
async function loadHolidays() {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
const start = new Date(props.startDate)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + count - 1)
|
||||
try {
|
||||
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
|
||||
|
||||
// --- Days computation ---
|
||||
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()],
|
||||
holiday: holidays.value[dateStr] ?? null,
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Overlap layout computation ---
|
||||
const MAX_VISIBLE_COLUMNS = 4
|
||||
|
||||
interface EntryLayout {
|
||||
entry: TimeEntry
|
||||
columnIndex: number
|
||||
totalColumns: number
|
||||
}
|
||||
|
||||
interface OverflowIndicator {
|
||||
topPx: number
|
||||
count: number
|
||||
hiddenEntries: TimeEntry[]
|
||||
}
|
||||
|
||||
function getEntryMinutes(entry: TimeEntry): { start: number; end: number } {
|
||||
const s = new Date(entry.startedAt)
|
||||
const startMin = s.getHours() * 60 + s.getMinutes()
|
||||
const e = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
|
||||
const endMin = e.getHours() * 60 + e.getMinutes()
|
||||
return { start: startMin, end: Math.max(endMin, startMin + 15) }
|
||||
}
|
||||
|
||||
function computeOverlapLayout(dayEntries: TimeEntry[]): { layouts: EntryLayout[]; overflows: OverflowIndicator[] } {
|
||||
if (dayEntries.length === 0) return { layouts: [], overflows: [] }
|
||||
|
||||
// Sort by start time, then by duration (longest first)
|
||||
const sorted = [...dayEntries].sort((a, b) => {
|
||||
const aM = getEntryMinutes(a)
|
||||
const bM = getEntryMinutes(b)
|
||||
if (aM.start !== bM.start) return aM.start - bM.start
|
||||
return (bM.end - bM.start) - (aM.end - aM.start)
|
||||
})
|
||||
|
||||
// Group overlapping entries into clusters
|
||||
const clusters: TimeEntry[][] = []
|
||||
let currentCluster: TimeEntry[] = []
|
||||
let clusterEnd = 0
|
||||
|
||||
for (const entry of sorted) {
|
||||
const { start, end } = getEntryMinutes(entry)
|
||||
if (currentCluster.length === 0 || start < clusterEnd) {
|
||||
currentCluster.push(entry)
|
||||
clusterEnd = Math.max(clusterEnd, end)
|
||||
} else {
|
||||
clusters.push(currentCluster)
|
||||
currentCluster = [entry]
|
||||
clusterEnd = end
|
||||
}
|
||||
}
|
||||
if (currentCluster.length > 0) clusters.push(currentCluster)
|
||||
|
||||
const layouts: EntryLayout[] = []
|
||||
const overflows: OverflowIndicator[] = []
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Assign columns within this cluster
|
||||
const colEnds: number[] = []
|
||||
|
||||
const clusterAssignments: { entry: TimeEntry; col: number }[] = []
|
||||
|
||||
for (const entry of cluster) {
|
||||
const { start, end } = getEntryMinutes(entry)
|
||||
// Find first column where this entry fits
|
||||
let placed = false
|
||||
for (let c = 0; c < colEnds.length; c++) {
|
||||
if (colEnds[c]! <= start) {
|
||||
colEnds[c] = end
|
||||
clusterAssignments.push({ entry, col: c })
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
clusterAssignments.push({ entry, col: colEnds.length })
|
||||
colEnds.push(end)
|
||||
}
|
||||
}
|
||||
|
||||
const totalColumns = Math.min(colEnds.length, MAX_VISIBLE_COLUMNS)
|
||||
let hasOverflow = false
|
||||
|
||||
for (const { entry, col } of clusterAssignments) {
|
||||
if (col < MAX_VISIBLE_COLUMNS) {
|
||||
layouts.push({
|
||||
entry,
|
||||
columnIndex: col,
|
||||
totalColumns,
|
||||
})
|
||||
} else {
|
||||
hasOverflow = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOverflow) {
|
||||
const hidden = clusterAssignments.filter((a) => a.col >= MAX_VISIBLE_COLUMNS)
|
||||
const firstEntry = cluster[0]!
|
||||
const { start } = getEntryMinutes(firstEntry)
|
||||
overflows.push({
|
||||
topPx: (start / 60) * hourHeight,
|
||||
count: hidden.length,
|
||||
hiddenEntries: hidden.map((a) => a.entry),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { layouts, overflows }
|
||||
}
|
||||
|
||||
const layoutCache = computed(() => {
|
||||
const cache = new Map<string, { layouts: EntryLayout[]; overflows: OverflowIndicator[] }>()
|
||||
for (const day of days.value) {
|
||||
const dayEntries = entriesForDay(day.dateStr)
|
||||
cache.set(day.dateStr, computeOverlapLayout(dayEntries))
|
||||
}
|
||||
return cache
|
||||
})
|
||||
|
||||
function layoutForDay(dateStr: string): EntryLayout[] {
|
||||
return layoutCache.value.get(dateStr)?.layouts ?? []
|
||||
}
|
||||
|
||||
function overflowsForDay(dateStr: string): OverflowIndicator[] {
|
||||
return layoutCache.value.get(dateStr)?.overflows ?? []
|
||||
}
|
||||
|
||||
// --- Overflow popover ---
|
||||
interface OverflowPopoverState {
|
||||
dayIndex: number
|
||||
topPx: number
|
||||
entries: TimeEntry[]
|
||||
}
|
||||
|
||||
const overflowPopover = ref<OverflowPopoverState | null>(null)
|
||||
|
||||
function openOverflowPopover(dayIndex: number, overflow: OverflowIndicator) {
|
||||
overflowPopover.value = {
|
||||
dayIndex,
|
||||
topPx: overflow.topPx,
|
||||
entries: overflow.hiddenEntries,
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getSnappedMinutesFromY(y: number): number {
|
||||
return Math.max(0, Math.min(23 * 60 + 45, Math.round((y / hourHeight) * 60 / 15) * 15))
|
||||
}
|
||||
|
||||
function formatMinutes(totalMinutes: number): string {
|
||||
const h = Math.floor(totalMinutes / 60)
|
||||
const m = totalMinutes % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// --- Click to create ---
|
||||
let dragEndTime = 0
|
||||
|
||||
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
|
||||
// Suppress click right after drag end
|
||||
if (Date.now() - dragEndTime < 200) return
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const minutes = getSnappedMinutesFromY(y)
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 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)
|
||||
}
|
||||
|
||||
// --- Drag to move ---
|
||||
interface DragState {
|
||||
entryId: number
|
||||
entry: TimeEntry
|
||||
title: string
|
||||
color: string
|
||||
durationMinutes: number
|
||||
ghostHeightPx: number
|
||||
offsetY: number
|
||||
targetDayIndex: number
|
||||
ghostTopPx: number
|
||||
snappedMinutes: number
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
const dragState = ref<DragState | null>(null)
|
||||
let autoScrollActive = false
|
||||
let lastMouseEvent: MouseEvent | null = null
|
||||
|
||||
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
|
||||
const entry = payload.entry
|
||||
const startMinutes = new Date(entry.startedAt).getHours() * 60 + new Date(entry.startedAt).getMinutes()
|
||||
const endMinutes = entry.stoppedAt
|
||||
? new Date(entry.stoppedAt).getHours() * 60 + new Date(entry.stoppedAt).getMinutes()
|
||||
: startMinutes + 60
|
||||
const durationMinutes = endMinutes - startMinutes
|
||||
|
||||
dragState.value = {
|
||||
entryId: entry.id,
|
||||
entry,
|
||||
title: entry.title || t('common.untitled'),
|
||||
color: entry.project?.color ?? '#94a3b8',
|
||||
durationMinutes,
|
||||
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||
offsetY: payload.offsetY,
|
||||
targetDayIndex: sourceDayIndex,
|
||||
ghostTopPx: (startMinutes / 60) * hourHeight,
|
||||
snappedMinutes: startMinutes,
|
||||
timeLabel: `${formatMinutes(startMinutes)} – ${formatMinutes(endMinutes)}`,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'grabbing'
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function updateDragPosition(event: MouseEvent) {
|
||||
if (!dragState.value) return
|
||||
|
||||
// Find which column the cursor is over
|
||||
let targetDayIndex = dragState.value.targetDayIndex
|
||||
for (let i = 0; i < dayColumnEls.value.length; i++) {
|
||||
const el = dayColumnEls.value[i]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (event.clientX >= rect.left && event.clientX <= rect.right) {
|
||||
targetDayIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y position in the target column
|
||||
const targetCol = dayColumnEls.value[targetDayIndex]
|
||||
if (!targetCol) return
|
||||
const colRect = targetCol.getBoundingClientRect()
|
||||
const y = event.clientY - colRect.top - dragState.value.offsetY
|
||||
const snappedMinutes = getSnappedMinutesFromY(y)
|
||||
const endMinutes = snappedMinutes + dragState.value.durationMinutes
|
||||
|
||||
dragState.value.targetDayIndex = targetDayIndex
|
||||
dragState.value.snappedMinutes = snappedMinutes
|
||||
dragState.value.ghostTopPx = (snappedMinutes / 60) * hourHeight
|
||||
dragState.value.timeLabel = `${formatMinutes(snappedMinutes)} – ${formatMinutes(endMinutes)}`
|
||||
}
|
||||
|
||||
function onDragMove(event: MouseEvent) {
|
||||
if (!dragState.value) return
|
||||
event.preventDefault()
|
||||
lastMouseEvent = event
|
||||
updateDragPosition(event)
|
||||
|
||||
// Start auto-scroll if not running
|
||||
if (!autoScrollActive) {
|
||||
autoScrollActive = true
|
||||
requestAnimationFrame(autoScrollLoop)
|
||||
}
|
||||
}
|
||||
|
||||
function autoScrollLoop() {
|
||||
const scrollParent = getScrollParent()
|
||||
if (!autoScrollActive || !lastMouseEvent || !scrollParent || !dragState.value) {
|
||||
autoScrollActive = false
|
||||
return
|
||||
}
|
||||
|
||||
const rect = scrollParent.getBoundingClientRect()
|
||||
const edgeSize = 60
|
||||
const maxSpeed = 10
|
||||
|
||||
const distFromTop = lastMouseEvent.clientY - rect.top
|
||||
const distFromBottom = rect.bottom - lastMouseEvent.clientY
|
||||
|
||||
let scrolled = false
|
||||
if (distFromTop < edgeSize && distFromTop > 0) {
|
||||
scrollParent.scrollTop -= maxSpeed * (1 - distFromTop / edgeSize)
|
||||
scrolled = true
|
||||
} else if (distFromBottom < edgeSize && distFromBottom > 0) {
|
||||
scrollParent.scrollTop += maxSpeed * (1 - distFromBottom / edgeSize)
|
||||
scrolled = true
|
||||
}
|
||||
|
||||
// Update ghost position if we scrolled (scroll changes coordinate mapping)
|
||||
if (scrolled && lastMouseEvent) {
|
||||
updateDragPosition(lastMouseEvent)
|
||||
}
|
||||
|
||||
requestAnimationFrame(autoScrollLoop)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
autoScrollActive = false
|
||||
lastMouseEvent = null
|
||||
|
||||
if (!dragState.value) return
|
||||
|
||||
const state = dragState.value
|
||||
const targetDay = days.value[state.targetDayIndex]
|
||||
|
||||
if (targetDay) {
|
||||
const h = Math.floor(state.snappedMinutes / 60)
|
||||
const m = state.snappedMinutes % 60
|
||||
const newStart = new Date(targetDay.date)
|
||||
newStart.setHours(h, m, 0, 0)
|
||||
const newStop = new Date(newStart.getTime() + state.durationMinutes * 60000)
|
||||
|
||||
emit('moveEntry', state.entry, newStart.toISOString(), newStop.toISOString())
|
||||
}
|
||||
|
||||
dragState.value = null
|
||||
dragEndTime = Date.now()
|
||||
}
|
||||
</script>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Period presets -->
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="currentMonth"
|
||||
:label="$t('timeEntries.exportCurrentMonth')"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="lastMonth"
|
||||
:label="$t('timeEntries.exportLastMonth')"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="custom"
|
||||
:label="$t('timeEntries.exportCustomPeriod')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
|
||||
<input
|
||||
v-model="customFrom"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
|
||||
<input
|
||||
v-model="customTo"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User filter (admin only) -->
|
||||
<div v-if="isAdmin" class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedUserIds"
|
||||
:options="userOptions"
|
||||
:label="$t('timeEntries.exportUsers')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedClientId"
|
||||
:options="clientOptions"
|
||||
:label="$t('timeEntries.exportClient')"
|
||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Project filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedProjectIds"
|
||||
:options="filteredProjectOptions"
|
||||
:label="$t('timeEntries.exportProjects')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedTagIds"
|
||||
:options="tagOptions"
|
||||
:label="$t('timeEntries.exportTags')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Export button -->
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-full"
|
||||
@click="doExport"
|
||||
/>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
users: UserData[]
|
||||
projects: Project[]
|
||||
tags: TaskTag[]
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{
|
||||
(e: 'export', params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}): void
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
|
||||
const customFrom = ref('')
|
||||
const customTo = ref('')
|
||||
const selectedUserIds = ref<number[]>([])
|
||||
const selectedClientId = ref<number | null>(null)
|
||||
const selectedProjectIds = ref<number[]>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
const filteredProjectOptions = computed(() => {
|
||||
let list = props.projects
|
||||
if (selectedClientId.value) {
|
||||
list = list.filter(p => p.client?.id === selectedClientId.value)
|
||||
}
|
||||
return list.map(p => ({ label: p.name, value: p.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
props.tags.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
// Reset project selection when client changes
|
||||
watch(selectedClientId, () => {
|
||||
selectedProjectIds.value = []
|
||||
})
|
||||
|
||||
function getDateRange(): { after: string; before: string } {
|
||||
const now = new Date()
|
||||
if (periodMode.value === 'currentMonth') {
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||
return {
|
||||
after: first.toISOString().slice(0, 10),
|
||||
before: last.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
if (periodMode.value === 'lastMonth') {
|
||||
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
return {
|
||||
after: first.toISOString().slice(0, 10),
|
||||
before: last.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
return {
|
||||
after: customFrom.value,
|
||||
before: customTo.value,
|
||||
}
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
const { after, before } = getDateRange()
|
||||
if (!after || !before) return
|
||||
|
||||
emit('export', {
|
||||
after,
|
||||
before,
|
||||
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
|
||||
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
|
||||
client: selectedClientId.value ?? undefined,
|
||||
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user