diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 7d46a2a..3c3a49b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -360,7 +360,52 @@ "section": "Administration", "teamAbsences": "Absences équipe", "administration": "Administration", - "directory": "Répertoire" + "directory": "Répertoire", + "reporting": "Rapports" + } + }, + "reporting": { + "title": "Rapports", + "export": "Exporter (CSV)", + "empty": "Aucune donnée pour cette période.", + "filters": { + "period": "Période", + "from": "Du", + "to": "Au", + "project": "Projet", + "allProjects": "Tous les projets", + "user": "Utilisateur", + "allUsers": "Tous les utilisateurs" + }, + "periods": { + "thisMonth": "Mois courant", + "lastMonth": "Mois dernier", + "custom": "Personnalisé" + }, + "sections": { + "timePerProject": "Temps par projet", + "timePerUser": "Temps par utilisateur", + "tasksByStatus": "Tâches par statut", + "absencesByType": "Absences par type" + }, + "columns": { + "project": "Projet", + "code": "Code", + "user": "Utilisateur", + "status": "Statut", + "type": "Type", + "hours": "Heures", + "entries": "Nb saisies", + "count": "Nombre", + "days": "Jours" + }, + "absenceTypes": { + "cp": "Congés payés", + "mariage_pacs": "Mariage / PACS", + "naissance": "Naissance", + "conge_parental": "Congé parental", + "deces": "Décès proche", + "maladie": "Arrêt maladie" } }, "common": { diff --git a/frontend/modules/reporting/components/ReportingBarChart.vue b/frontend/modules/reporting/components/ReportingBarChart.vue new file mode 100644 index 0000000..0eb18e3 --- /dev/null +++ b/frontend/modules/reporting/components/ReportingBarChart.vue @@ -0,0 +1,52 @@ + + + + + {{ emptyMessage ?? $t('reporting.empty') }} + + + + + diff --git a/frontend/modules/reporting/components/ReportingDoughnut.vue b/frontend/modules/reporting/components/ReportingDoughnut.vue new file mode 100644 index 0000000..691faca --- /dev/null +++ b/frontend/modules/reporting/components/ReportingDoughnut.vue @@ -0,0 +1,56 @@ + + + + + {{ emptyMessage ?? $t('reporting.empty') }} + + + + + diff --git a/frontend/modules/reporting/composables/useCsvExport.ts b/frontend/modules/reporting/composables/useCsvExport.ts new file mode 100644 index 0000000..25c7a98 --- /dev/null +++ b/frontend/modules/reporting/composables/useCsvExport.ts @@ -0,0 +1,44 @@ +export type CsvColumn = { + header: string + value: (row: T) => string | number +} + +/** Escapes a single CSV cell (quotes wrapping + doubled inner quotes). */ +function escapeCell(value: string | number): string { + const str = String(value ?? '') + if (/[",\n;]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export function useCsvExport() { + /** Builds a CSV string from rows + column definitions (semicolon separator, FR-friendly). */ + function toCsv(rows: T[], columns: CsvColumn[]): string { + const header = columns.map(c => escapeCell(c.header)).join(';') + const lines = rows.map(row => + columns.map(c => escapeCell(c.value(row))).join(';'), + ) + return [header, ...lines].join('\n') + } + + /** Triggers a browser download of the given CSV content. */ + function downloadCsv(filename: string, rows: T[], columns: CsvColumn[]): void { + if (typeof window === 'undefined') { + return + } + const csv = toCsv(rows, columns) + // Prepend BOM so Excel detects UTF-8. + const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename.endsWith('.csv') ? filename : `${filename}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return { toCsv, downloadCsv } +} diff --git a/frontend/modules/reporting/composables/useReportingFilters.ts b/frontend/modules/reporting/composables/useReportingFilters.ts new file mode 100644 index 0000000..bdc60de --- /dev/null +++ b/frontend/modules/reporting/composables/useReportingFilters.ts @@ -0,0 +1,41 @@ +export type ReportPeriodKey = 'thisMonth' | 'lastMonth' | 'custom' + +export type ReportPeriodRange = { + from: string + to: string +} + +/** Formats a Date as an ISO "YYYY-MM-DD" string (local time, no timezone shift). */ +function toIsoDate(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** Returns the {from, to} range (inclusive) for a given month offset relative to now. */ +function getMonthRange(offset: number): ReportPeriodRange { + const now = new Date() + const start = new Date(now.getFullYear(), now.getMonth() + offset, 1) + const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0) + return { from: toIsoDate(start), to: toIsoDate(end) } +} + +export function useReportingFilters() { + const thisMonth = (): ReportPeriodRange => getMonthRange(0) + const lastMonth = (): ReportPeriodRange => getMonthRange(-1) + + /** Resolves a preset key to a concrete range. `custom` keeps the provided range. */ + function rangeForPreset(key: ReportPeriodKey, current: ReportPeriodRange): ReportPeriodRange { + switch (key) { + case 'thisMonth': + return thisMonth() + case 'lastMonth': + return lastMonth() + case 'custom': + return current + } + } + + return { thisMonth, lastMonth, rangeForPreset, toIsoDate } +} diff --git a/frontend/modules/reporting/nuxt.config.ts b/frontend/modules/reporting/nuxt.config.ts new file mode 100644 index 0000000..268da7f --- /dev/null +++ b/frontend/modules/reporting/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/frontend/modules/reporting/pages/reporting.vue b/frontend/modules/reporting/pages/reporting.vue new file mode 100644 index 0000000..8fc220b --- /dev/null +++ b/frontend/modules/reporting/pages/reporting.vue @@ -0,0 +1,388 @@ + + + + + {{ $t('reporting.title') }} + + + + + + + + {{ $t('reporting.filters.from') }} + + + + + + {{ $t('reporting.filters.to') }} + + + + + + + + + + + {{ $t('common.loading') }} + + + + + + + + {{ $t('reporting.sections.timePerProject') }} + + + + + + + {{ formatHours((item as TimePerProject).hours) }} + + + + + + + + + + + {{ $t('reporting.sections.timePerUser') }} + + + + + + + {{ formatHours((item as TimePerUser).hours) }} + + + + + + + + + + + {{ $t('reporting.sections.tasksByStatus') }} + + + + + + + + + + + + + + {{ $t('reporting.sections.absencesByType') }} + + + + + + + + + + + + + diff --git a/frontend/modules/reporting/services/dto/reporting.ts b/frontend/modules/reporting/services/dto/reporting.ts new file mode 100644 index 0000000..4494fd7 --- /dev/null +++ b/frontend/modules/reporting/services/dto/reporting.ts @@ -0,0 +1,34 @@ +export type TimePerProject = { + projectId: number + projectCode: string + projectName: string + hours: number + entryCount: number +} + +export type TimePerUser = { + userId: number + username: string + fullName: string + hours: number + entryCount: number +} + +export type TasksByStatus = { + statusId: number + statusLabel: string + count: number +} + +export type AbsencesByType = { + type: string + count: number + totalDays: number +} + +export type ReportFilters = { + from: string + to: string + userId?: number | null + projectId?: number | null +} diff --git a/frontend/modules/reporting/services/reporting.ts b/frontend/modules/reporting/services/reporting.ts new file mode 100644 index 0000000..95aff37 --- /dev/null +++ b/frontend/modules/reporting/services/reporting.ts @@ -0,0 +1,51 @@ +import type { + AbsencesByType, + ReportFilters, + TasksByStatus, + TimePerProject, + TimePerUser, +} from './dto/reporting' +import type { AnyObject } from '~/shared/composables/useApi' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +function buildQuery(filters: Partial, keys: (keyof ReportFilters)[]): AnyObject { + const query: AnyObject = {} + for (const key of keys) { + const value = filters[key] + if (value !== undefined && value !== null && value !== '') { + query[key] = value + } + } + return query +} + +export function useReportingService() { + const api = useApi() + + async function timePerProject(filters: ReportFilters): Promise { + const query = buildQuery(filters, ['from', 'to', 'userId', 'projectId']) + const data = await api.get>('/reports/time-per-project', query, { toast: false }) + return extractHydraMembers(data) + } + + async function timePerUser(filters: ReportFilters): Promise { + const query = buildQuery(filters, ['from', 'to', 'projectId']) + const data = await api.get>('/reports/time-per-user', query, { toast: false }) + return extractHydraMembers(data) + } + + async function tasksByStatus(filters: ReportFilters): Promise { + const query = buildQuery(filters, ['projectId']) + const data = await api.get>('/reports/tasks-by-status', query, { toast: false }) + return extractHydraMembers(data) + } + + async function absencesByType(filters: ReportFilters): Promise { + const query = buildQuery(filters, ['from', 'to', 'userId']) + const data = await api.get>('/reports/absences-by-type', query, { toast: false }) + return extractHydraMembers(data) + } + + return { timePerProject, timePerUser, tasksByStatus, absencesByType } +}
+ {{ emptyMessage ?? $t('reporting.empty') }} +
{{ $t('common.loading') }}