Compare commits

...

5 Commits

Author SHA1 Message Date
f888a29e0a refactor(frontend) : make page headers and filters sticky across all pages
Wrap title + filters in a sticky container (top-8 sm:top-12, z-20, bg-white)
on all pages for consistent scroll behavior. Also fix SidebarTimer icon
visibility when sidebar is collapsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:21:45 +01:00
b48ca10304 feat : populate all projects with tasks, groups and time entries in fixtures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:14:15 +01:00
802659434f fix(frontend) : fix time-tracking page scroll with fixed header and filters
Restructure time-tracking page layout so the page title and filters
stay fixed while only the calendar grid body scrolls internally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:10:04 +01:00
25aef9b2d5 feat : add dashboard with Chart.js charts and filters
Implement the dashboard page with real data from the API:
- KPI cards (hours, active tasks, total tasks, projects)
- Charts: hours by day (line), hours by project (doughnut),
  tasks by status (doughnut), tasks by priority (bar),
  tasks by project (horizontal stacked bar)
- Filters: period (week/month), project, user
- Add chart.js and vue-chartjs dependencies
- Add dashboard sidebar icon and translations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:05:35 +01:00
0733ac16cd feat : add project archiving feature
Allow projects to be archived/unarchived from the ProjectDrawer, with a
toggle filter on the projects page to show/hide archived projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 08:58:29 +01:00
28 changed files with 1612 additions and 440 deletions

View File

@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,

View File

@@ -53,6 +53,17 @@
</button>
</div>
</form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
<button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting"
@click="handleArchiveToggle"
>
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button>
</div>
</AppDrawer>
</template>
@@ -171,6 +182,21 @@ async function handleSubmit() {
}
}
async function handleArchiveToggle() {
if (!props.project) return
isSubmitting.value = true
try {
const newArchived = !props.project.archived
await update(props.project.id, { archived: newArchived }, {
toastSuccessKey: newArchived ? 'projects.archived' : 'projects.unarchived',
})
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
try {
giteaRepos.value = await listRepositories()

View File

@@ -14,7 +14,7 @@
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-8 py-5">
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
@@ -38,7 +38,7 @@
</div>
<!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-8 py-6">
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Title -->
<MalioInputText
v-model="form.title"
@@ -49,7 +49,7 @@
/>
<!-- Two-column selects -->
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-4">
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect
v-model="form.statusId"
:options="statusOptions"

View File

@@ -1,9 +1,8 @@
<template>
<div ref="calendarEl" class="relative rounded-lg border border-neutral-200 bg-white">
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- Day headers -->
<div
class="sticky z-20 flex border-b border-neutral-200 bg-white"
:style="{ top: `${stickyOffset}px` }"
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg"
>
<div class="w-16 shrink-0 border-r border-neutral-200" />
<div
@@ -22,7 +21,7 @@
</div>
<!-- Grid body -->
<div ref="gridBodyEl" class="relative flex">
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
<!-- Hour labels -->
<div class="w-16 shrink-0">
<div

View File

@@ -1,10 +1,16 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-12 text-xl text-white">
<div class="group relative flex gap-4">
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5">
<div class="flex h-full items-center justify-between">
<button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@click="ui.openMobileSidebar()"
>
<Icon name="mdi:menu" size="24" />
</button>
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
<div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="self-center cursor-pointer">{{ user?.username }}</p>
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
@@ -34,6 +40,7 @@ defineProps<{
}>()
const auth = useAuthStore()
const ui = useUiStore()
const handleLogout = async () => {
await auth.logout()

View File

@@ -37,7 +37,7 @@
<slot name="actions" :item="item" />
<button
v-if="deletable"
class="text-[red-500] hover:text-[red-700]"
class="text-neutral-400 transition-colors hover:text-red-500"
@click.stop="$emit('delete', item)"
>
<Icon name="mdi:delete-outline" size="20" />

View File

@@ -1,15 +1,18 @@
<template>
<button
class="flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white transition"
:class="timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600'"
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition"
:class="[
timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600',
collapsed ? 'px-2' : 'px-4'
]"
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
>
<Icon
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
size="16"
:size="collapsed ? '20' : '16'"
/>
<span v-if="!collapsed" class="font-mono tracking-wide">
{{ timerStore.elapsedFormatted }}

View File

@@ -27,7 +27,11 @@
"projects": {
"created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès."
"deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivés",
"hideArchived": "Masquer les projets archivés"
},
"taskStatuses": {
"created": "Statut créé avec succès.",
@@ -99,6 +103,53 @@
"noTasks": "Aucune tâche",
"backlog": "Backlog"
},
"dashboard": {
"title": "Tableau de bord",
"noData": "Aucune donnée",
"noPriority": "Sans priorité",
"noProject": "Sans projet",
"hoursWorked": "Heures travaillées",
"inProgress": "En cours",
"done": "Terminé",
"filters": {
"period": "Période",
"project": "Projet",
"user": "Utilisateur",
"allProjects": "Tous les projets",
"allUsers": "Tous les utilisateurs"
},
"periods": {
"thisWeek": "Cette semaine",
"lastWeek": "Semaine dernière",
"thisMonth": "Ce mois",
"lastMonth": "Mois dernier"
},
"stats": {
"hoursPeriod": "Heures sur la période",
"myActiveTasks": "Mes tâches actives",
"completed": "terminée(s)",
"totalTasks": "Tâches totales",
"unassigned": "non assignée(s)",
"projects": "Projets",
"users": "utilisateur(s)"
},
"charts": {
"hoursByDay": "Heures par jour",
"hoursByProject": "Temps par projet",
"tasksByStatus": "Tâches par statut",
"tasksByPriority": "Tâches par priorité",
"tasksByProject": "Tâches par projet"
},
"days": {
"mon": "Lun",
"tue": "Mar",
"wed": "Mer",
"thu": "Jeu",
"fri": "Ven",
"sat": "Sam",
"sun": "Dim"
}
},
"sidebar": {
"myTasks": "Mes tâches"
},

View File

@@ -1,13 +1,25 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-all duration-300"
:class="ui.sidebarCollapsed ? 'w-16' : 'w-64'"
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="[
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<div class="flex items-center justify-center overflow-hidden" :class="ui.sidebarCollapsed ? 'p-2' : ''">
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''">
<img
v-if="!ui.sidebarCollapsed"
v-if="!sidebarIsCollapsed"
src="/malio.png"
alt="Logo"
class="w-auto"
@@ -18,49 +30,61 @@
alt="Logo"
class="h-8 w-8 object-cover object-left"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 overflow-hidden" :class="ui.sidebarCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<SidebarLink
to="/"
icon="mdi:question-mark"
icon="mdi:view-dashboard-outline"
label="Tableau de bord"
:collapsed="ui.sidebarCollapsed"
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
:collapsed="sidebarIsCollapsed"
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
to="/my-tasks"
icon="mdi:clipboard-check-outline"
label="Mes tâches"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
to="/projects"
icon="mdi:folder-outline"
label="Projets"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<template v-if="currentProjectId">
<SidebarLink
:to="`/projects/${currentProjectId}`"
icon="mdi:view-column-outline"
label="Kanban"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
sub
exact
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/groups`"
icon="mdi:tag-multiple-outline"
label="Groupes"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
:to="`/projects/${currentProjectId}/archives`"
icon="mdi:archive-outline"
label="Archives"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
sub
@click="ui.closeMobileSidebar()"
/>
</template>
@@ -68,24 +92,26 @@
to="/time-tracking"
icon="mdi:clock-outline"
label="Suivi de temps"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
to="/admin"
icon="mdi:cog-outline"
label="Administration"
:collapsed="ui.sidebarCollapsed"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
</nav>
<div class="px-4 py-3">
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex flex-col gap-2 items-center p-4">
<p v-if="!ui.sidebarCollapsed" class="font-bold">v {{ version }}</p>
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
<button
class="flex items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors"
class="hidden items-center justify-center rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
@@ -99,8 +125,8 @@
<div class="h-full flex-1 flex flex-col min-h-0">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto bg-white px-16 pb-24">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 bg-white" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
</div>
@@ -129,6 +155,17 @@ const ui = useUiStore()
const {version} = useAppVersion()
const route = useRoute()
// On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false
return ui.sidebarCollapsed
})
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
const currentProjectId = computed(() => {
const match = route.path.match(/^\/projects\/(\d+)/)
return match ? match[1] : null
@@ -211,3 +248,14 @@ const handleLogout = async () => {
await navigateTo('/login')
}
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>

View File

@@ -12,10 +12,12 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
},
@@ -72,6 +74,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1027,7 +1030,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1037,7 +1039,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
@@ -1052,7 +1053,6 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1"
},
@@ -1065,7 +1065,6 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -1078,7 +1077,6 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
@@ -1088,7 +1086,6 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
@@ -1102,7 +1099,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -1112,7 +1108,6 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -1126,7 +1121,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
},
@@ -1140,7 +1134,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18.18"
},
@@ -2118,6 +2111,12 @@
"node": ">= 12"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@kwsites/file-exists": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@@ -2414,6 +2413,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
"license": "MIT",
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2486,6 +2486,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.27",
"defu": "^6.1.4",
@@ -3132,6 +3133,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.95.0"
},
@@ -5237,8 +5239,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -5250,8 +5251,7 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -5586,6 +5586,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
@@ -5779,6 +5780,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5818,7 +5820,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6150,6 +6151,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6343,6 +6345,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6471,6 +6474,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6607,6 +6611,19 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -6636,6 +6653,7 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -7169,8 +7187,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -7670,7 +7687,6 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
@@ -7701,7 +7717,6 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -7714,7 +7729,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7727,7 +7741,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -7740,7 +7753,6 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -7750,7 +7762,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
@@ -7768,7 +7779,6 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
@@ -7794,7 +7804,6 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -7807,7 +7816,6 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -7908,8 +7916,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
@@ -7937,15 +7944,13 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fast-npm-meta": {
"version": "1.4.0",
@@ -7990,7 +7995,6 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -8021,7 +8025,6 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -8038,7 +8041,6 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -8051,8 +8053,7 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -8585,7 +8586,6 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@@ -8962,22 +8962,19 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -9055,7 +9052,6 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -9316,7 +9312,6 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -9401,7 +9396,6 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -9793,8 +9787,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
@@ -10030,6 +10023,7 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dxup/nuxt": "^0.3.2",
"@nuxt/cli": "^3.33.0",
@@ -10300,7 +10294,6 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10352,6 +10345,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -10435,7 +10429,6 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -10451,7 +10444,6 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -10494,7 +10486,6 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -10598,6 +10589,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -10714,6 +10706,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11257,6 +11250,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -11307,7 +11301,6 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -11344,7 +11337,6 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -11706,6 +11698,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12488,6 +12481,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -12828,7 +12822,6 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -12896,6 +12889,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13331,7 +13325,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@@ -13356,6 +13349,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13717,6 +13711,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -13742,6 +13737,16 @@
"ufo": "^1.6.1"
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-devtools-stub": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
@@ -13753,6 +13758,7 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
@@ -13774,6 +13780,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -13826,7 +13833,6 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13995,7 +14001,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},

View File

@@ -16,10 +16,12 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
}

View File

@@ -1,24 +1,26 @@
<template>
<div>
<h1 class="text-2xl font-bold text-primary-500">Administration</h1>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
<div class="mt-6 border-b border-neutral-200">
<nav class="flex gap-6">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key
? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</nav>
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-4 sm:gap-6">
<button
v-for="tab in tabs"
:key="tab.key"
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key
? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</nav>
</div>
</div>
<div class="mt-6">
<div>
<AdminClientTab v-if="activeTab === 'clients'" />
<AdminStatusTab v-if="activeTab === 'statuses'" />
<AdminEffortTab v-if="activeTab === 'efforts'" />

View File

@@ -1,7 +1,668 @@
<template>
<h1 class="text-primary-500">Tableau de bord</h1>
</template>
<script setup lang="ts">
import { Doughnut, Bar, Line } from 'vue-chartjs'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TimeEntry } from '~/services/dto/time-entry'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTimeEntryService } from '~/services/time-entries'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('dashboard.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const priorityService = useTaskPriorityService()
const timeEntryService = useTimeEntryService()
const projectService = useProjectService()
const userService = useUserService()
const allTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const priorities = ref<TaskPriority[]>([])
const allTimeEntries = ref<TimeEntry[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
// ── Filters ──
type PeriodKey = 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
const selectedPeriod = ref<PeriodKey>('thisWeek')
const selectedProjectId = ref<number | null>(null)
const selectedUserId = ref<number | null>(null)
const periodOptions = computed(() => [
{ label: t('dashboard.periods.thisWeek'), value: 'thisWeek' },
{ label: t('dashboard.periods.lastWeek'), value: 'lastWeek' },
{ label: t('dashboard.periods.thisMonth'), value: 'thisMonth' },
{ label: t('dashboard.periods.lastMonth'), value: 'lastMonth' },
])
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const userOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// ── Period date ranges ──
function getWeekRange(offset: number = 0) {
const now = new Date()
const day = now.getDay()
const diffToMonday = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diffToMonday + offset * 7)
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
return { start: monday, end: sunday }
}
function getMonthRange(offset: number = 0) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
end.setHours(23, 59, 59, 999)
return { start, end }
}
const dateRange = computed(() => {
switch (selectedPeriod.value) {
case 'thisWeek': return getWeekRange(0)
case 'lastWeek': return getWeekRange(-1)
case 'thisMonth': return getMonthRange(0)
case 'lastMonth': return getMonthRange(-1)
}
})
const isWeekPeriod = computed(() =>
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
)
// ── Filtered data (client-side project filter) ──
const tasks = computed(() => {
if (!selectedProjectId.value) return allTasks.value
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
})
const timeEntries = computed(() => {
if (!selectedProjectId.value) return allTimeEntries.value
return allTimeEntries.value.filter(e => e.project?.id === selectedProjectId.value)
})
// ── Data loading ──
async function loadReferenceData() {
const [s, p, proj, u] = await Promise.all([
statusService.getAll(),
priorityService.getAll(),
projectService.getAll(),
userService.getAll(),
])
statuses.value = s
priorities.value = p
projects.value = proj
users.value = u
}
async function loadTasks() {
allTasks.value = await taskService.getFiltered({ archived: false })
}
async function loadTimeEntries() {
const params: { after: string; before: string; user?: number } = {
after: dateRange.value.start.toISOString(),
before: dateRange.value.end.toISOString(),
}
if (selectedUserId.value) {
params.user = selectedUserId.value
}
allTimeEntries.value = await timeEntryService.getByDateRange(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks(), loadTimeEntries()])
} finally {
isLoading.value = false
}
}
// Reload time entries when period or user changes (server-side filter)
watch([selectedPeriod, selectedUserId], () => {
loadTimeEntries()
})
onMounted(() => loadAll())
// ── Helpers ──
function durationHours(entry: TimeEntry): number {
const start = new Date(entry.startedAt)
const end = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
return (end.getTime() - start.getTime()) / 3_600_000
}
function formatHours(h: number): string {
const hours = Math.floor(h)
const mins = Math.round((h - hours) * 60)
return mins > 0 ? `${hours}h${String(mins).padStart(2, '0')}` : `${hours}h`
}
// ── KPI Stats ──
const totalHoursThisWeek = computed(() =>
timeEntries.value.reduce((sum, e) => sum + durationHours(e), 0)
)
const myTasks = computed(() =>
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
)
const myTasksDone = computed(() =>
myTasks.value.filter(t => t.status?.isFinal)
)
const unassignedTasks = computed(() =>
tasks.value.filter(t => !t.assignee)
)
// ── Chart: Tasks by Status (Doughnut) ──
const tasksByStatusData = computed(() => {
const sorted = [...statuses.value].sort((a, b) => a.position - b.position)
const noStatus = tasks.value.filter(t => !t.status).length
const labels = noStatus > 0 ? ['Backlog', ...sorted.map(s => s.label)] : sorted.map(s => s.label)
const data = noStatus > 0
? [noStatus, ...sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)]
: sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)
const colors = noStatus > 0
? ['#9ca3af', ...sorted.map(s => s.color)]
: sorted.map(s => s.color)
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
}],
}
})
// ── Chart: Tasks by Priority (Bar) ──
const tasksByPriorityData = computed(() => {
const sorted = [...priorities.value]
const noPriority = tasks.value.filter(t => !t.priority).length
const labels = [...sorted.map(p => p.label), ...(noPriority > 0 ? [t('dashboard.noPriority')] : [])]
const data = [...sorted.map(p => tasks.value.filter(t => t.priority?.id === p.id).length), ...(noPriority > 0 ? [noPriority] : [])]
const colors = [...sorted.map(p => p.color), ...(noPriority > 0 ? ['#9ca3af'] : [])]
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
borderRadius: 6,
}],
}
})
// ── Chart: Hours by Project (Doughnut) ──
const hoursByProjectData = computed(() => {
const projectHours = new Map<number, { name: string; color: string; hours: number }>()
let noProjectHours = 0
for (const entry of timeEntries.value) {
const h = durationHours(entry)
if (entry.project) {
const existing = projectHours.get(entry.project.id)
if (existing) {
existing.hours += h
} else {
projectHours.set(entry.project.id, {
name: entry.project.name,
color: entry.project.color || '#6366f1',
hours: h,
})
}
} else {
noProjectHours += h
}
}
const entries = [...projectHours.values()].sort((a, b) => b.hours - a.hours)
if (noProjectHours > 0) {
entries.push({ name: t('dashboard.noProject'), color: '#9ca3af', hours: noProjectHours })
}
return {
labels: entries.map(e => e.name),
datasets: [{
data: entries.map(e => Math.round(e.hours * 100) / 100),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
}],
}
})
// ── Chart: Hours by Day (Line) ──
const weekDayLabels = [
t('dashboard.days.mon'),
t('dashboard.days.tue'),
t('dashboard.days.wed'),
t('dashboard.days.thu'),
t('dashboard.days.fri'),
t('dashboard.days.sat'),
t('dashboard.days.sun'),
]
const hoursByDayData = computed(() => {
if (isWeekPeriod.value) {
const dayHours = new Array(7).fill(0)
for (const entry of timeEntries.value) {
const start = new Date(entry.startedAt)
const dayIndex = start.getDay() === 0 ? 6 : start.getDay() - 1
dayHours[dayIndex] += durationHours(entry)
}
return {
labels: weekDayLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: dayHours.map(h => Math.round(h * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
}
// Month view: group by week number
const { start, end } = dateRange.value
const weekMap = new Map<string, number>()
const weekLabels: string[] = []
// Build week labels for the month
const cursor = new Date(start)
while (cursor <= end) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > end) weekEnd.setTime(end.getTime())
const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1} - ${weekEnd.getDate()}/${weekEnd.getMonth() + 1}`
weekLabels.push(label)
weekMap.set(label, 0)
cursor.setDate(cursor.getDate() + 7)
// Align to Monday
const d = cursor.getDay()
if (d !== 1) {
cursor.setDate(cursor.getDate() + (d === 0 ? 1 : 8 - d))
}
}
for (const entry of timeEntries.value) {
const entryDate = new Date(entry.startedAt)
for (let i = 0; i < weekLabels.length; i++) {
const parts = weekLabels[i].split(' - ')
const [sd, sm] = parts[0].split('/').map(Number)
const [ed, em] = parts[1].split('/').map(Number)
const ws = new Date(start.getFullYear(), sm - 1, sd)
const we = new Date(start.getFullYear(), em - 1, ed, 23, 59, 59)
if (entryDate >= ws && entryDate <= we) {
weekMap.set(weekLabels[i], (weekMap.get(weekLabels[i]) ?? 0) + durationHours(entry))
break
}
}
}
return {
labels: weekLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: weekLabels.map(l => Math.round((weekMap.get(l) ?? 0) * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
})
// ── Chart: Tasks by Project (Horizontal Bar) ──
const tasksByProjectData = computed(() => {
const projectTasks = new Map<number, { name: string; color: string; count: number; done: number }>()
for (const task of tasks.value) {
if (!task.project) continue
const existing = projectTasks.get(task.project.id)
const isDone = task.status?.isFinal ?? false
if (existing) {
existing.count++
if (isDone) existing.done++
} else {
projectTasks.set(task.project.id, {
name: task.project.name,
color: task.project.color || '#6366f1',
count: 1,
done: isDone ? 1 : 0,
})
}
}
const entries = [...projectTasks.values()].sort((a, b) => b.count - a.count)
return {
labels: entries.map(e => e.name),
datasets: [
{
label: t('dashboard.inProgress'),
data: entries.map(e => e.count - e.done),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
borderRadius: 6,
},
{
label: t('dashboard.done'),
data: entries.map(e => e.done),
backgroundColor: entries.map(e => e.color + '66'),
borderWidth: 0,
borderRadius: 6,
},
],
}
})
// ── Chart options ──
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
}
const barOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
x: {
grid: { display: false },
},
},
}
const horizontalBarOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
y: {
stacked: true,
grid: { display: false },
},
},
}
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: any) => `${formatHours(ctx.raw)}`,
},
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f3f4f6' },
ticks: {
callback: (value: any) => `${value}h`,
},
},
x: {
grid: { display: false },
},
},
}
</script>
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedPeriod"
:options="periodOptions"
:label="$t('dashboard.filters.period')"
min-width="!w-48"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
:label="$t('dashboard.filters.project')"
:empty-option-label="$t('dashboard.filters.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
:label="$t('dashboard.filters.user')"
:empty-option-label="$t('dashboard.filters.allUsers')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
<p class="text-neutral-400">{{ $t('common.loading') }}</p>
</div>
<template v-else>
<!-- KPI Cards -->
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.hoursPeriod') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ formatHours(totalHoursThisWeek) }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.myActiveTasks') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ myTasks.length - myTasksDone.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ myTasksDone.length }} {{ $t('dashboard.stats.completed') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.totalTasks') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ tasks.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ unassignedTasks.length }} {{ $t('dashboard.stats.unassigned') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.projects') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ projects.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ users.length }} {{ $t('dashboard.stats.users') }}
</p>
</div>
</div>
<!-- Charts Row 1 -->
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<!-- Hours by Day (Line) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByDay') }}
</h2>
<div class="mt-4 h-64">
<Line :data="hoursByDayData" :options="lineOptions" />
</div>
</div>
<!-- Hours by Project (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByProject') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="hoursByProjectData.labels.length > 0"
:data="hoursByProjectData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Tasks by Status (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByStatus') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="tasksByStatusData.labels.length > 0"
:data="tasksByStatusData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
<!-- Tasks by Priority (Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByPriority') }}
</h2>
<div class="mt-4 h-64">
<Bar
v-if="tasksByPriorityData.labels.length > 0"
:data="tasksByPriorityData"
:options="barOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="mt-6">
<!-- Tasks by Project (Horizontal Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByProject') }}
</h2>
<div class="mt-4" :style="{ height: Math.max(200, tasksByProjectData.labels.length * 40 + 60) + 'px' }">
<Bar
v-if="tasksByProjectData.labels.length > 0"
:data="tasksByProjectData"
:options="horizontalBarOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -230,103 +230,135 @@ onMounted(() => {
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="20" />
</button>
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="20" />
</button>
<!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="20" />
</button>
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="20" />
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
label="Projet"
:empty-option-label="$t('myTasks.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedGroupId"
:options="groupOptions"
label="Groupe"
:empty-option-label="$t('myTasks.allGroups')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
label="Type"
:empty-option-label="$t('myTasks.allTypes')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityOptions"
label="Priorité"
:empty-option-label="$t('myTasks.allPriorities')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortOptions"
label="Effort"
:empty-option-label="$t('myTasks.allEfforts')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="assigneeOptions"
label="Assigné"
:empty-option-label="$t('myTasks.allAssignees')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
label="Projet"
:empty-option-label="$t('myTasks.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedGroupId"
:options="groupOptions"
label="Groupe"
:empty-option-label="$t('myTasks.allGroups')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
label="Type"
:empty-option-label="$t('myTasks.allTypes')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityOptions"
label="Priorité"
:empty-option-label="$t('myTasks.allPriorities')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortOptions"
label="Effort"
:empty-option-label="$t('myTasks.allEfforts')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="assigneeOptions"
label="Assigné"
:empty-option-label="$t('myTasks.allAssignees')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
<!-- Kanban View -->
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
<!-- Backlog column (tasks without status) -->
<div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@dragleave="onDragLeave"
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
</div>
<!-- Backlog below kanban -->
<div
v-if="backlogTasks.length > 0"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(0)"
@dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)"
>
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
</div>
<div class="flex flex-col gap-3 p-3">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<TaskCard
v-for="task in backlogTasks"
:key="task.id"
@@ -334,39 +366,12 @@ onMounted(() => {
@click="openTaskEdit(task)"
/>
</div>
</div>
<!-- Status columns -->
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@dragleave="onDragLeave"
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
<p
v-if="backlogTasks.length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
@@ -375,12 +380,12 @@ onMounted(() => {
<div
v-for="task in tasks"
:key="task.id"
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
@click="openTaskEdit(task)"
>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="mt-1 flex items-center gap-1.5">
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"

View File

@@ -1,20 +1,22 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} {{ $t('archive.title') }}</h1>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} {{ $t('archive.title') }}</h1>
</div>
<div class="mt-4">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="w-64"
/>
</div>
</div>
<div class="mt-4">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="w-64"
/>
</div>
<div class="mt-6">
<div>
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
{{ $t('archive.empty') }}
</p>

View File

@@ -1,10 +1,12 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }} Groupes</h1>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} Groupes</h1>
</div>
</div>
<div class="mt-6">
<div>
<ProjectGroupTab :project-id="projectId" />
</div>
</div>

View File

@@ -1,52 +1,55 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }}</h1>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openTaskCreate"
>
+ Ajouter un ticket
</button>
</div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagFilterOptions"
label="Tags"
empty-option-label="Tous les tags"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="userFilterOptions"
label="User"
empty-option-label="Tous les users"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedStatusId"
:options="statusFilterOptions"
label="Status"
empty-option-label="Tous les status"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedGroupId"
:options="groupFilterOptions"
label="Groupe"
empty-option-label="Tous les groupes"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagFilterOptions"
label="Tags"
empty-option-label="Tous les tags"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="userFilterOptions"
label="User"
empty-option-label="Tous les users"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedStatusId"
:options="statusFilterOptions"
label="Status"
empty-option-label="Tous les status"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Kanban -->
@@ -93,55 +96,14 @@
@dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)"
>
<h2 class="text-lg font-bold text-neutral-900">Backlog</h2>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<div
<h2 class="text-lg font-bold text-neutral-900">Backlog ({{ backlogTasks.length }})</h2>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<TaskCard
v-for="task in backlogTasks"
:key="task.id"
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
draggable="true"
@dragstart="onBacklogDragStart($event, task)"
@dragend="onBacklogDragEnd"
:task="task"
@click="openTaskEdit(task)"
>
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
<div class="flex items-center gap-2">
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<span
v-if="task.effort"
class="text-sm font-bold text-neutral-700"
>
{{ task.effort.label }}
</span>
<span
v-if="task.assignee"
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
<span
v-else
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
</div>
</div>
/>
</div>
</div>
@@ -307,15 +269,6 @@ function onDrop(event: DragEvent) {
return Number(event.dataTransfer!.getData('text/plain'))
}
function onBacklogDragStart(event: DragEvent, task: Task) {
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(task.id))
;(event.target as HTMLElement).classList.add('opacity-50')
}
function onBacklogDragEnd(event: DragEvent) {
;(event.target as HTMLElement).classList.remove('opacity-50')
}
async function onDropStatus(event: DragEvent, status: TaskStatus) {
const taskId = onDrop(event)

View File

@@ -1,25 +1,47 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">Projets</h1>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un projet
</button>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
<div class="flex items-center gap-2 sm:gap-3">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
:class="showArchived
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'"
@click="toggleArchived"
>
<Icon :name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'" size="18" />
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
</button>
<button
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openCreate"
>
<span class="hidden sm:inline">+ Ajouter un projet</span>
<span class="sm:hidden">+ Projet</span>
</button>
</div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="project in projects"
:key="project.id"
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
:class="{ 'opacity-60': project.archived }"
@click="navigateTo(`/projects/${project.id}`)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h3 class="text-md font-bold" :style="{ color: project.color }">{{ project.name }}</h3>
<span
v-if="project.archived"
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
>
Archivé
</span>
</div>
<button
class="p-1 text-neutral-400 hover:text-primary-500"
@@ -37,7 +59,7 @@
v-if="projects.length === 0 && !isLoading"
class="col-span-full py-12 text-center text-neutral-400"
>
Aucun projet trouvé.
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
</div>
</div>
@@ -66,12 +88,13 @@ const clients = ref<Client[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedProject = ref<Project | null>(null)
const showArchived = ref(false)
async function loadData() {
isLoading.value = true
try {
const [p, c] = await Promise.all([
projectService.getAll(),
projectService.getAll({ archived: showArchived.value }),
clientService.getAll(),
])
projects.value = p
@@ -81,6 +104,11 @@ async function loadData() {
}
}
function toggleArchived() {
showArchived.value = !showArchived.value
loadData()
}
function openCreate() {
selectedProject.value = null
drawerOpen.value = true

View File

@@ -1,72 +1,73 @@
<template>
<div>
<div ref="pageHeaderEl" class="sticky top-0 z-40 bg-white pb-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">Suivi des temps</h1>
<div class="flex min-h-0 flex-1 flex-col">
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-primary-600 transition sm:px-4 sm:text-sm"
@click="openCreateDrawer()"
>
+ Ajouter une Activité
<span class="hidden sm:inline">+ Ajouter une Activité</span>
<span class="sm:hidden">+ Activité</span>
</button>
</div>
<div class="relative z-30 mt-4 flex items-center gap-4">
<h2 class="text-lg font-bold text-orange-500">
{{ currentMonthLabel }}
</h2>
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
<h2 class="text-lg font-bold text-orange-500">
{{ currentMonthLabel }}
</h2>
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<button
v-for="mode in (['week', 'day', 'list'] as const)"
:key="mode"
class="px-3 py-1 text-sm font-semibold transition"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
@click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
</button>
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<button
v-for="mode in (['week', 'day', 'list'] as const)"
:key="mode"
class="px-3 py-1 text-sm font-semibold transition"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
@click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
</button>
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
label="User"
empty-option-label="User"
/>
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
label="User"
empty-option-label="User"
/>
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
empty-option-label="Tous"
label="Projet"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
empty-option-label="Tous"
label="Projet"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
empty-option-label="Tous"
label="Tag"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
empty-option-label="Tous"
label="Tag"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div class="mt-4">
<div class="mt-4 -mb-24 min-h-0 flex-1">
<TimeEntryList
v-if="viewMode === 'list'"
:entries="filteredEntries"

View File

@@ -0,0 +1,28 @@
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
ArcElement,
BarElement,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
} from 'chart.js'
export default defineNuxtPlugin(() => {
ChartJS.register(
Title,
Tooltip,
Legend,
ArcElement,
BarElement,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
)
})

View File

@@ -10,6 +10,7 @@ export type Project = {
client: Client | null
giteaOwner: string | null
giteaRepo: string | null
archived: boolean
}
export type ProjectWrite = {
@@ -20,4 +21,5 @@ export type ProjectWrite = {
client: string | null // IRI : "/api/clients/1" ou null
giteaOwner?: string | null
giteaRepo?: string | null
archived?: boolean
}

View File

@@ -5,8 +5,9 @@ import { extractHydraMembers } from '~/utils/api'
export function useProjectService() {
const api = useApi()
async function getAll(): Promise<Project[]> {
const data = await api.get<HydraCollection<Project>>('/projects')
async function getAll(params?: { archived?: boolean }): Promise<Project[]> {
const query = params?.archived !== undefined ? `?archived=${params.archived}` : ''
const data = await api.get<HydraCollection<Project>>(`/projects${query}`)
return extractHydraMembers(data)
}
@@ -20,9 +21,9 @@ export function useProjectService() {
})
}
async function update(id: number, payload: Partial<ProjectWrite>): Promise<Project> {
async function update(id: number, payload: Partial<ProjectWrite>, options?: { toastSuccessKey?: string }): Promise<Project> {
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'projects.updated',
toastSuccessKey: options?.toastSuccessKey ?? 'projects.updated',
})
}

View File

@@ -1,5 +1,6 @@
export const useUiStore = defineStore('ui', () => {
const sidebarCollapsed = ref(false)
const sidebarOpen = ref(false)
if (import.meta.client) {
const saved = localStorage.getItem('ui-sidebar-collapsed')
@@ -18,5 +19,13 @@ export const useUiStore = defineStore('ui', () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return { sidebarCollapsed, toggleSidebar }
function openMobileSidebar() {
sidebarOpen.value = true
}
function closeMobileSidebar() {
sidebarOpen.value = false
}
return { sidebarCollapsed, sidebarOpen, toggleSidebar, openMobileSidebar, closeMobileSidebar }
})

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260314075537 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project ADD archived BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project DROP archived');
}
}

View File

@@ -177,7 +177,7 @@ class AppFixtures extends Fixture
$tagCalendar->setColor('#222783');
$manager->persist($tagCalendar);
// Task Groups
// Task Groups — SIRH
$groupFrontend = new TaskGroup();
$groupFrontend->setTitle('Frontend');
$groupFrontend->setColor('#4A90D9');
@@ -190,7 +190,48 @@ class AppFixtures extends Fixture
$groupBackend->setProject($projectSirh);
$manager->persist($groupBackend);
// Tasks
// Task Groups — CRM
$groupCrmUi = new TaskGroup();
$groupCrmUi->setTitle('Interface');
$groupCrmUi->setColor('#E91E63');
$groupCrmUi->setProject($projectCrm);
$manager->persist($groupCrmUi);
$groupCrmApi = new TaskGroup();
$groupCrmApi->setTitle('API');
$groupCrmApi->setColor('#9C27B0');
$groupCrmApi->setProject($projectCrm);
$manager->persist($groupCrmApi);
// Task Groups — ERP
$groupErpStock = new TaskGroup();
$groupErpStock->setTitle('Stocks');
$groupErpStock->setColor('#4A90D9');
$groupErpStock->setProject($projectErp);
$manager->persist($groupErpStock);
$groupErpFacturation = new TaskGroup();
$groupErpFacturation->setTitle('Facturation');
$groupErpFacturation->setColor('#FF8F00');
$groupErpFacturation->setProject($projectErp);
$manager->persist($groupErpFacturation);
// Task Groups — Site vitrine
$groupSiteDesign = new TaskGroup();
$groupSiteDesign->setTitle('Design');
$groupSiteDesign->setColor('#26A69A');
$groupSiteDesign->setProject($projectInterne);
$manager->persist($groupSiteDesign);
$groupSiteContenu = new TaskGroup();
$groupSiteContenu->setTitle('Contenu');
$groupSiteContenu->setColor('#795548');
$groupSiteContenu->setProject($projectInterne);
$manager->persist($groupSiteContenu);
// =============================================
// Tasks — SIRH
// =============================================
$task1 = new Task();
$task1->setNumber(1);
$task1->setTitle('Création d\'une page de login');
@@ -260,8 +301,199 @@ class AppFixtures extends Fixture
$task6->addTag($tagAuth);
$manager->persist($task6);
// --- Time Entries (SIRH project, admin user) ---
// =============================================
// Tasks — CRM
// =============================================
$taskCrm1 = new Task();
$taskCrm1->setNumber(1);
$taskCrm1->setTitle('Liste des contacts');
$taskCrm1->setStatus($statusDone);
$taskCrm1->setEffort($effortL);
$taskCrm1->setPriority($priorityHigh);
$taskCrm1->setAssignee($admin);
$taskCrm1->setGroup($groupCrmUi);
$taskCrm1->setProject($projectCrm);
$manager->persist($taskCrm1);
$taskCrm2 = new Task();
$taskCrm2->setNumber(2);
$taskCrm2->setTitle('Fiche contact détaillée');
$taskCrm2->setStatus($statusInProgress);
$taskCrm2->setEffort($effortM);
$taskCrm2->setPriority($priorityMedium);
$taskCrm2->setAssignee($admin);
$taskCrm2->setGroup($groupCrmUi);
$taskCrm2->setProject($projectCrm);
$manager->persist($taskCrm2);
$taskCrm3 = new Task();
$taskCrm3->setNumber(3);
$taskCrm3->setTitle('Import CSV contacts');
$taskCrm3->setStatus($statusTodo);
$taskCrm3->setEffort($effortXL);
$taskCrm3->setPriority($priorityLow);
$taskCrm3->setAssignee($admin);
$taskCrm3->setGroup($groupCrmApi);
$taskCrm3->setProject($projectCrm);
$manager->persist($taskCrm3);
$taskCrm4 = new Task();
$taskCrm4->setNumber(4);
$taskCrm4->setTitle('Pipeline de vente');
$taskCrm4->setStatus($statusInProgress);
$taskCrm4->setEffort($effortXXL);
$taskCrm4->setPriority($priorityHigh);
$taskCrm4->setAssignee($admin);
$taskCrm4->setGroup($groupCrmUi);
$taskCrm4->setProject($projectCrm);
$taskCrm4->addTag($tagCalendar);
$manager->persist($taskCrm4);
$taskCrm5 = new Task();
$taskCrm5->setNumber(5);
$taskCrm5->setTitle('API recherche contacts');
$taskCrm5->setStatus($statusReview);
$taskCrm5->setEffort($effortM);
$taskCrm5->setPriority($priorityMedium);
$taskCrm5->setAssignee($admin);
$taskCrm5->setGroup($groupCrmApi);
$taskCrm5->setProject($projectCrm);
$manager->persist($taskCrm5);
// =============================================
// Tasks — ERP
// =============================================
$taskErp1 = new Task();
$taskErp1->setNumber(1);
$taskErp1->setTitle('Tableau de bord stocks');
$taskErp1->setStatus($statusDone);
$taskErp1->setEffort($effortL);
$taskErp1->setPriority($priorityHigh);
$taskErp1->setAssignee($admin);
$taskErp1->setGroup($groupErpStock);
$taskErp1->setProject($projectErp);
$manager->persist($taskErp1);
$taskErp2 = new Task();
$taskErp2->setNumber(2);
$taskErp2->setTitle('Alertes stock bas');
$taskErp2->setStatus($statusInProgress);
$taskErp2->setEffort($effortM);
$taskErp2->setPriority($priorityHigh);
$taskErp2->setAssignee($admin);
$taskErp2->setGroup($groupErpStock);
$taskErp2->setProject($projectErp);
$manager->persist($taskErp2);
$taskErp3 = new Task();
$taskErp3->setNumber(3);
$taskErp3->setTitle('Génération factures PDF');
$taskErp3->setStatus($statusTodo);
$taskErp3->setEffort($effortXXL);
$taskErp3->setPriority($priorityMedium);
$taskErp3->setAssignee($admin);
$taskErp3->setGroup($groupErpFacturation);
$taskErp3->setProject($projectErp);
$manager->persist($taskErp3);
$taskErp4 = new Task();
$taskErp4->setNumber(4);
$taskErp4->setTitle('Historique mouvements stock');
$taskErp4->setStatus($statusReview);
$taskErp4->setEffort($effortL);
$taskErp4->setPriority($priorityLow);
$taskErp4->setAssignee($admin);
$taskErp4->setGroup($groupErpStock);
$taskErp4->setProject($projectErp);
$manager->persist($taskErp4);
$taskErp5 = new Task();
$taskErp5->setNumber(5);
$taskErp5->setTitle('Export comptable');
$taskErp5->setStatus($statusBlocked);
$taskErp5->setEffort($effortXL);
$taskErp5->setPriority($priorityHigh);
$taskErp5->setAssignee($admin);
$taskErp5->setGroup($groupErpFacturation);
$taskErp5->setProject($projectErp);
$manager->persist($taskErp5);
$taskErp6 = new Task();
$taskErp6->setNumber(6);
$taskErp6->setTitle('Inventaire annuel');
$taskErp6->setStatus($statusTodo);
$taskErp6->setEffort($effortS);
$taskErp6->setPriority($priorityLow);
$taskErp6->setAssignee($admin);
$taskErp6->setGroup($groupErpStock);
$taskErp6->setProject($projectErp);
$taskErp6->addTag($tagCalendar);
$manager->persist($taskErp6);
// =============================================
// Tasks — Site vitrine
// =============================================
$taskSite1 = new Task();
$taskSite1->setNumber(1);
$taskSite1->setTitle('Maquette page d\'accueil');
$taskSite1->setStatus($statusDone);
$taskSite1->setEffort($effortM);
$taskSite1->setPriority($priorityHigh);
$taskSite1->setAssignee($admin);
$taskSite1->setGroup($groupSiteDesign);
$taskSite1->setProject($projectInterne);
$manager->persist($taskSite1);
$taskSite2 = new Task();
$taskSite2->setNumber(2);
$taskSite2->setTitle('Intégration responsive');
$taskSite2->setStatus($statusInProgress);
$taskSite2->setEffort($effortL);
$taskSite2->setPriority($priorityMedium);
$taskSite2->setAssignee($admin);
$taskSite2->setGroup($groupSiteDesign);
$taskSite2->setProject($projectInterne);
$manager->persist($taskSite2);
$taskSite3 = new Task();
$taskSite3->setNumber(3);
$taskSite3->setTitle('Rédaction page "À propos"');
$taskSite3->setStatus($statusTodo);
$taskSite3->setEffort($effortS);
$taskSite3->setPriority($priorityLow);
$taskSite3->setAssignee($admin);
$taskSite3->setGroup($groupSiteContenu);
$taskSite3->setProject($projectInterne);
$manager->persist($taskSite3);
$taskSite4 = new Task();
$taskSite4->setNumber(4);
$taskSite4->setTitle('Formulaire de contact');
$taskSite4->setStatus($statusReview);
$taskSite4->setEffort($effortM);
$taskSite4->setPriority($priorityMedium);
$taskSite4->setAssignee($admin);
$taskSite4->setGroup($groupSiteDesign);
$taskSite4->setProject($projectInterne);
$taskSite4->addTag($tagAuth);
$manager->persist($taskSite4);
$taskSite5 = new Task();
$taskSite5->setNumber(5);
$taskSite5->setTitle('SEO et métadonnées');
$taskSite5->setStatus($statusTodo);
$taskSite5->setEffort($effortS);
$taskSite5->setPriority($priorityHigh);
$taskSite5->setAssignee($admin);
$taskSite5->setGroup($groupSiteContenu);
$taskSite5->setProject($projectInterne);
$manager->persist($taskSite5);
// =============================================
// Time Entries — tous les projets
// =============================================
$timeEntryData = [
// SIRH — lundi à vendredi
['title' => 'Réunion', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:00', 'stop' => '09:45', 'day' => 1],
['title' => 'Page accueil', 'project' => $projectSirh, 'tag' => $tagPassword, 'start' => '10:00', 'stop' => '12:00', 'day' => 0],
['title' => 'Design admin', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '09:30', 'stop' => '11:00', 'day' => 2],
@@ -272,6 +504,27 @@ class AppFixtures extends Fixture
['title' => 'Script backup BDD', 'project' => $projectSirh, 'tag' => $tagAuth, 'start' => '13:30', 'stop' => '15:00', 'day' => 3],
['title' => 'Maquette', 'project' => $projectSirh, 'tag' => null, 'start' => '09:00', 'stop' => '11:00', 'day' => 4],
['title' => 'PC compta', 'project' => $projectSirh, 'tag' => null, 'start' => '13:30', 'stop' => '15:30', 'day' => 4],
// CRM — lundi à vendredi
['title' => 'Liste contacts UI', 'project' => $projectCrm, 'tag' => null, 'start' => '08:30', 'stop' => '10:00', 'day' => 0],
['title' => 'Fiche contact', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 0],
['title' => 'Pipeline vente', 'project' => $projectCrm, 'tag' => $tagCalendar, 'start' => '08:30', 'stop' => '09:30', 'day' => 1],
['title' => 'Import CSV', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:30', 'day' => 2],
['title' => 'API recherche', 'project' => $projectCrm, 'tag' => null, 'start' => '08:30', 'stop' => '10:00', 'day' => 3],
['title' => 'Tests unitaires CRM', 'project' => $projectCrm, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 3],
['title' => 'Revue pipeline', 'project' => $projectCrm, 'tag' => $tagCalendar, 'start' => '08:00', 'stop' => '09:00', 'day' => 4],
// ERP — lundi à vendredi
['title' => 'Dashboard stocks', 'project' => $projectErp, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 1],
['title' => 'Alertes stock bas', 'project' => $projectErp, 'tag' => null, 'start' => '11:30', 'stop' => '12:30', 'day' => 2],
['title' => 'Factures PDF', 'project' => $projectErp, 'tag' => null, 'start' => '15:30', 'stop' => '17:30', 'day' => 4],
['title' => 'Mouvement stock', 'project' => $projectErp, 'tag' => null, 'start' => '09:00', 'stop' => '10:30', 'day' => 0],
['title' => 'Export comptable', 'project' => $projectErp, 'tag' => $tagCalendar, 'start' => '13:00', 'stop' => '14:30', 'day' => 2],
['title' => 'Inventaire', 'project' => $projectErp, 'tag' => null, 'start' => '13:30', 'stop' => '15:00', 'day' => 4],
// Site vitrine — lundi à jeudi
['title' => 'Maquette accueil', 'project' => $projectInterne, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 0],
['title' => 'Responsive mobile', 'project' => $projectInterne, 'tag' => null, 'start' => '16:00', 'stop' => '17:30', 'day' => 2],
['title' => 'Rédaction contenu', 'project' => $projectInterne, 'tag' => null, 'start' => '15:30', 'stop' => '17:00', 'day' => 1],
['title' => 'Formulaire contact', 'project' => $projectInterne, 'tag' => $tagAuth, 'start' => '16:00', 'stop' => '17:30', 'day' => 3],
['title' => 'SEO meta tags', 'project' => $projectInterne, 'tag' => null, 'start' => '11:00', 'stop' => '12:00', 'day' => 4],
];
$monday = new DateTimeImmutable('monday this week', new DateTimeZone('UTC'));

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -31,6 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['project:write']],
order: ['name' => 'ASC'],
)]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
class Project
@@ -48,7 +51,7 @@ class Project
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
@@ -56,7 +59,7 @@ class Project
private ?string $description = null;
#[ORM\Column(length: 7)]
#[Groups(['project:read', 'project:write', 'time_entry:read'])]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
private ?string $color = '#222783';
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')]
@@ -72,6 +75,10 @@ class Project
#[Groups(['project:read', 'project:write', 'task:read'])]
private ?string $giteaRepo = null;
#[ORM\Column]
#[Groups(['project:read', 'project:write'])]
private bool $archived = false;
public function getId(): ?int
{
return $this->id;
@@ -165,4 +172,16 @@ class Project
{
return null !== $this->giteaOwner && null !== $this->giteaRepo;
}
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
}

View File

@@ -13,6 +13,7 @@ use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final readonly class GiteaApiService
{
@@ -203,7 +204,11 @@ final readonly class GiteaApiService
throw new GiteaApiException('Gitea token is not set.');
}
return $this->tokenEncryptor->decrypt($encrypted);
try {
return $this->tokenEncryptor->decrypt($encrypted);
} catch (Throwable $e) {
throw new GiteaApiException('Failed to decrypt Gitea token: '.$e->getMessage(), 0, $e);
}
}
private function assertProjectHasRepo(Project $project): void

View File

@@ -4,31 +4,51 @@ declare(strict_types=1);
namespace App\Service;
use InvalidArgumentException;
use App\Exception\GiteaApiException;
use RuntimeException;
use SodiumException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class TokenEncryptor
final class TokenEncryptor
{
private string $key;
private readonly string $key;
private readonly bool $configured;
public function __construct(
#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')]
string $encryptionKey,
) {
if ('' === $encryptionKey) {
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY environment variable must be set.');
$this->key = '';
$this->configured = false;
return;
}
$this->key = sodium_hex2bin($encryptionKey);
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
$this->key = '';
$this->configured = false;
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($this->key, '8bit')) {
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY must be a valid sodium secret box key.');
return;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
$this->key = '';
$this->configured = false;
return;
}
$this->key = $key;
$this->configured = true;
}
public function encrypt(string $plaintext): string
{
$this->assertConfigured();
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
@@ -37,6 +57,8 @@ final readonly class TokenEncryptor
public function decrypt(string $encrypted): string
{
$this->assertConfigured();
$decoded = sodium_hex2bin($encrypted);
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
@@ -49,4 +71,11 @@ final readonly class TokenEncryptor
return $plaintext;
}
private function assertConfigured(): void
{
if (!$this->configured) {
throw new GiteaApiException('Gitea encryption is not configured. Please set a valid GITEA_ENCRYPTION_KEY.');
}
}
}