Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7968af525 | ||
| df2a48c20d | |||
| 7f1c02256b | |||
| fdc9b8b60d | |||
| 1025fed0d1 | |||
| 0331d94ca5 | |||
| 755c39a0f6 | |||
| 8f8eeddd91 | |||
| 548b101d82 |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.10'
|
app.version: '0.3.11'
|
||||||
|
|||||||
213
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
213
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal 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>
|
||||||
@@ -162,7 +162,21 @@
|
|||||||
"noEntries": "Aucune activité pour cette période",
|
"noEntries": "Aucune activité pour cette période",
|
||||||
"addEntry": "Ajouter une Activité",
|
"addEntry": "Ajouter une Activité",
|
||||||
"editEntry": "Modifier un temps",
|
"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": {
|
"archive": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<button
|
<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"
|
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" />
|
<Icon name="mdi:download" size="18" />
|
||||||
{{ $t('timeEntries.export') }}
|
{{ $t('timeEntries.export') }}
|
||||||
@@ -128,6 +128,15 @@
|
|||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TimeTrackingExportDrawer
|
||||||
|
v-model="exportDrawerOpen"
|
||||||
|
:users="users"
|
||||||
|
:projects="projects"
|
||||||
|
:tags="tags"
|
||||||
|
:clients="clients"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,6 +145,7 @@ import type { TimeEntry } from '~/services/dto/time-entry'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useTimeEntryService } from '~/services/time-entries'
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
import type { HydraCollection } from '~/utils/api'
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
@@ -156,6 +166,8 @@ const entries = ref<TimeEntry[]>([])
|
|||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
|
const exportDrawerOpen = ref(false)
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const editingEntry = ref<TimeEntry | null>(null)
|
const editingEntry = ref<TimeEntry | null>(null)
|
||||||
@@ -305,38 +317,35 @@ async function onDelete(entry: TimeEntry) {
|
|||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExportDateRange(): { after: string, before: string } {
|
async function onExport(params: {
|
||||||
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
|
after: string
|
||||||
return {
|
before: string
|
||||||
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
|
users?: number[]
|
||||||
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
|
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() {
|
async function loadEntries() {
|
||||||
@@ -353,15 +362,17 @@ async function loadEntries() {
|
|||||||
async function loadReferenceData() {
|
async function loadReferenceData() {
|
||||||
const api = useApi()
|
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<UserData>>('/users'),
|
||||||
api.get<HydraCollection<Project>>('/projects'),
|
api.get<HydraCollection<Project>>('/projects'),
|
||||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||||
|
api.get<HydraCollection<Client>>('/clients'),
|
||||||
])
|
])
|
||||||
|
|
||||||
users.value = extractHydraMembers(usersData)
|
users.value = extractHydraMembers(usersData)
|
||||||
projects.value = extractHydraMembers(projectsData)
|
projects.value = extractHydraMembers(projectsData)
|
||||||
tags.value = extractHydraMembers(typesData)
|
tags.value = extractHydraMembers(typesData)
|
||||||
|
clients.value = extractHydraMembers(clientsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -53,20 +53,42 @@ export function useTimeEntryService() {
|
|||||||
function getExportUrl(params: {
|
function getExportUrl(params: {
|
||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
users?: number[]
|
||||||
project?: number
|
projects?: number[]
|
||||||
|
client?: number
|
||||||
tags?: number[]
|
tags?: number[]
|
||||||
}): string {
|
}): string {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
query.set('after', params.after)
|
query.set('after', params.after)
|
||||||
query.set('before', params.before)
|
query.set('before', params.before)
|
||||||
if (params.user) query.set('user', String(params.user))
|
if (params.users?.length) {
|
||||||
if (params.project) query.set('project', String(params.project))
|
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) {
|
if (params.tags?.length) {
|
||||||
params.tags.forEach(id => query.append('tags[]', String(id)))
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,27 +47,65 @@ class TimeEntryExportController extends AbstractController
|
|||||||
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
|
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max range: 12 months
|
|
||||||
if ($after->modify('+12 months') < $before) {
|
if ($after->modify('+12 months') < $before) {
|
||||||
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: non-admin users can only export their own data
|
// --- Users ---
|
||||||
$user = null;
|
$users = null;
|
||||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
/** @var User $user */
|
/** @var User $currentUser */
|
||||||
$user = $this->security->getUser();
|
$currentUser = $this->security->getUser();
|
||||||
|
$users = [$currentUser];
|
||||||
} else {
|
} else {
|
||||||
$userId = $request->query->getInt('user');
|
/** @var int[] $userIds */
|
||||||
if ($userId > 0) {
|
$userIds = array_filter(
|
||||||
$user = $this->entityManager->getRepository(User::class)->find($userId);
|
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;
|
// --- Client (filter projects by client) ---
|
||||||
$projectId = $request->query->getInt('project');
|
$clientId = $request->query->getInt('client');
|
||||||
if ($projectId > 0) {
|
$clientProjects = null;
|
||||||
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
|
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 */
|
/** @var int[] $tagIds */
|
||||||
@@ -79,8 +117,8 @@ class TimeEntryExportController extends AbstractController
|
|||||||
$entries = $this->timeEntryRepository->findForExport(
|
$entries = $this->timeEntryRepository->findForExport(
|
||||||
$after,
|
$after,
|
||||||
$before,
|
$before,
|
||||||
$user,
|
$users ?: null,
|
||||||
$project,
|
$projects ?: null,
|
||||||
$tagIds ?: null,
|
$tagIds ?: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
* @return TimeEntry[]
|
||||||
*/
|
*/
|
||||||
public function findForExport(
|
public function findForExport(
|
||||||
DateTimeImmutable $after,
|
DateTimeImmutable $after,
|
||||||
DateTimeImmutable $before,
|
DateTimeImmutable $before,
|
||||||
?User $user = null,
|
?array $users = null,
|
||||||
?Project $project = null,
|
?array $projects = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createQueryBuilder('te')
|
$qb = $this->createQueryBuilder('te')
|
||||||
@@ -49,15 +51,15 @@ class TimeEntryRepository extends ServiceEntityRepository
|
|||||||
->orderBy('te.startedAt', 'ASC')
|
->orderBy('te.startedAt', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
if (null !== $user) {
|
if (null !== $users && [] !== $users) {
|
||||||
$qb->andWhere('te.user = :user')
|
$qb->andWhere('te.user IN (:users)')
|
||||||
->setParameter('user', $user)
|
->setParameter('users', $users)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $project) {
|
if (null !== $projects && [] !== $projects) {
|
||||||
$qb->andWhere('te.project = :project')
|
$qb->andWhere('te.project IN (:projects)')
|
||||||
->setParameter('project', $project)
|
->setParameter('projects', $projects)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user