Compare commits

...

9 Commits

Author SHA1 Message Date
gitea-actions
d7968af525 chore: bump version to v0.3.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m49s
2026-03-25 17:42:21 +00:00
df2a48c20d fix : remove double /api prefix in export URL
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
7f1c02256b fix : replace MalioButton with styled native button in export drawer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
fdc9b8b60d fix : use correct useToast() API in export handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
1025fed0d1 feat : integrate export drawer with async background download
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
0331d94ca5 feat : add TimeTrackingExportDrawer component with filters and period presets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
755c39a0f6 feat : extend export endpoint for multi-user, multi-project, client filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
8f8eeddd91 feat : add downloadExport async method to time-entries service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
548b101d82 feat : add i18n keys for export modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:41:05 +01:00
7 changed files with 364 additions and 64 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.10'
app.version: '0.3.11'

View File

@@ -0,0 +1,213 @@
<template>
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
<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"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Client filter -->
<div class="[&>div]:!mt-0">
<MalioSelect
v-model="selectedClientId"
:options="clientOptions"
:label="$t('timeEntries.exportClient')"
:empty-option-label="$t('timeEntries.exportAllClients')"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</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"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</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"
min-width="!w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Export button -->
<button
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-600 transition"
@click="doExport"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
</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>

View File

@@ -162,7 +162,21 @@
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps",
"export": "Exporter"
"export": "Exporter",
"exportTitle": "Exporter les temps",
"exportCurrentMonth": "Mois en cours",
"exportLastMonth": "Mois dernier",
"exportCustomPeriod": "Période personnalisée",
"exportFrom": "Du",
"exportTo": "Au",
"exportUsers": "Utilisateurs",
"exportClient": "Client",
"exportProjects": "Projets",
"exportTags": "Tags",
"exportAllClients": "Tous les clients",
"exportLoading": "Export en cours...",
"exportSuccess": "Export terminé !",
"exportError": "Erreur lors de l'export."
},
"archive": {
"title": "Archives",

View File

@@ -78,7 +78,7 @@
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
@click="exportDrawerOpen = true"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
@@ -128,6 +128,15 @@
@paste="onPaste"
@delete="onDelete"
/>
<TimeTrackingExportDrawer
v-model="exportDrawerOpen"
:users="users"
:projects="projects"
:tags="tags"
:clients="clients"
@export="onExport"
/>
</div>
</template>
@@ -136,6 +145,7 @@ 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'
@@ -156,6 +166,8 @@ 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)
@@ -305,38 +317,35 @@ async function onDelete(entry: TimeEntry) {
await loadEntries()
}
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
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') })
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
async function loadEntries() {
@@ -353,15 +362,17 @@ async function loadEntries() {
async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
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 () => {

View File

@@ -53,20 +53,42 @@ export function useTimeEntryService() {
function getExportUrl(params: {
after: string
before: string
user?: number
project?: number
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): string {
const query = new URLSearchParams()
query.set('after', params.after)
query.set('before', params.before)
if (params.user) query.set('user', String(params.user))
if (params.project) query.set('project', String(params.project))
if (params.users?.length) {
params.users.forEach(id => query.append('users[]', String(id)))
}
if (params.client) query.set('client', String(params.client))
if (params.projects?.length) {
params.projects.forEach(id => query.append('projects[]', String(id)))
}
if (params.tags?.length) {
params.tags.forEach(id => query.append('tags[]', String(id)))
}
return `/api/time_entries/export?${query.toString()}`
return `/time_entries/export?${query.toString()}`
}
return { getByDateRange, getActive, create, update, remove, getExportUrl }
async function downloadExport(params: {
after: string
before: string
users?: number[]
projects?: number[]
client?: number
tags?: number[]
}): Promise<{ blob: Blob; filename: string }> {
const url = getExportUrl(params)
const response = await api.getBlob(url)
const disposition = response.headers.get('content-disposition') ?? ''
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
const filename = filenameMatch?.[1] ?? `export-temps-${params.after}_${params.before}.xlsx`
return { blob: response.data, filename }
}
return { getByDateRange, getActive, create, update, remove, getExportUrl, downloadExport }
}

View File

@@ -47,27 +47,65 @@ class TimeEntryExportController extends AbstractController
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
}
// Max range: 12 months
if ($after->modify('+12 months') < $before) {
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
}
// Authorization: non-admin users can only export their own data
$user = null;
// --- Users ---
$users = null;
if (!$this->security->isGranted('ROLE_ADMIN')) {
/** @var User $user */
$user = $this->security->getUser();
/** @var User $currentUser */
$currentUser = $this->security->getUser();
$users = [$currentUser];
} else {
$userId = $request->query->getInt('user');
if ($userId > 0) {
$user = $this->entityManager->getRepository(User::class)->find($userId);
/** @var int[] $userIds */
$userIds = array_filter(
array_map('intval', (array) $request->query->all('users')),
fn (int $id) => $id > 0,
);
if ([] !== $userIds) {
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
}
}
$project = null;
$projectId = $request->query->getInt('project');
if ($projectId > 0) {
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
// --- Client (filter projects by client) ---
$clientId = $request->query->getInt('client');
$clientProjects = null;
if ($clientId > 0) {
$clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]);
}
// --- Projects ---
$projects = null;
/** @var int[] $projectIds */
$projectIds = array_filter(
array_map('intval', (array) $request->query->all('projects')),
fn (int $id) => $id > 0,
);
if ([] !== $projectIds) {
$projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]);
}
// Merge: if both client and projects are set, intersect; if only client, use client projects
if (null !== $clientProjects && null !== $projects) {
$clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects);
$projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true)));
if ([] === $projects) {
$projects = null;
// No matching projects — force empty result by using a dummy condition
$entries = [];
$tempFile = $this->exportService->generate($entries, $after, $before);
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
$response = new BinaryFileResponse($tempFile);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->deleteFileAfterSend(true);
return $response;
}
} elseif (null !== $clientProjects) {
$projects = $clientProjects;
}
/** @var int[] $tagIds */
@@ -79,8 +117,8 @@ class TimeEntryExportController extends AbstractController
$entries = $this->timeEntryRepository->findForExport(
$after,
$before,
$user,
$project,
$users ?: null,
$projects ?: null,
$tagIds ?: null,
);

View File

@@ -30,15 +30,17 @@ class TimeEntryRepository extends ServiceEntityRepository
}
/**
* @param null|int[] $tagIds
* @param null|User[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?User $user = null,
?Project $project = null,
?array $users = null,
?array $projects = null,
?array $tagIds = null,
): array {
$qb = $this->createQueryBuilder('te')
@@ -49,15 +51,15 @@ class TimeEntryRepository extends ServiceEntityRepository
->orderBy('te.startedAt', 'ASC')
;
if (null !== $user) {
$qb->andWhere('te.user = :user')
->setParameter('user', $user)
if (null !== $users && [] !== $users) {
$qb->andWhere('te.user IN (:users)')
->setParameter('users', $users)
;
}
if (null !== $project) {
$qb->andWhere('te.project = :project')
->setParameter('project', $project)
if (null !== $projects && [] !== $projects) {
$qb->andWhere('te.project IN (:projects)')
->setParameter('projects', $projects)
;
}