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>
This commit is contained in:
2026-03-14 09:21:45 +01:00
parent b48ca10304
commit f888a29e0a
17 changed files with 429 additions and 358 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 * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration * 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. * max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{ * default_options?: array{
* headers?: array<string, mixed>, * headers?: array<string, mixed>,

View File

@@ -14,7 +14,7 @@
style="max-height: min(90vh, 900px)" style="max-height: min(90vh, 900px)"
> >
<!-- Header --> <!-- 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 justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
@@ -38,7 +38,7 @@
</div> </div>
<!-- Body --> <!-- 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 --> <!-- Title -->
<MalioInputText <MalioInputText
v-model="form.title" v-model="form.title"
@@ -49,7 +49,7 @@
/> />
<!-- Two-column selects --> <!-- 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 <MalioSelect
v-model="form.statusId" v-model="form.statusId"
:options="statusOptions" :options="statusOptions"

View File

@@ -1,10 +1,16 @@
<template> <template>
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white"> <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-end"> <div class="flex h-full items-center justify-between">
<div class="flex gap-12 text-xl text-white"> <button
<div class="group relative flex gap-4"> 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" /> <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"> <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 <button
type="button" type="button"
@@ -34,6 +40,7 @@ defineProps<{
}>() }>()
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore()
const handleLogout = async () => { const handleLogout = async () => {
await auth.logout() await auth.logout()

View File

@@ -37,7 +37,7 @@
<slot name="actions" :item="item" /> <slot name="actions" :item="item" />
<button <button
v-if="deletable" 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)" @click.stop="$emit('delete', item)"
> >
<Icon name="mdi:delete-outline" size="20" /> <Icon name="mdi:delete-outline" size="20" />

View File

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

View File

@@ -1,13 +1,25 @@
<template> <template>
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<div class="flex h-full"> <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 <aside
class="flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-all duration-300" 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 ? 'w-16' : 'w-64'" :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 <img
v-if="!ui.sidebarCollapsed" v-if="!sidebarIsCollapsed"
src="/malio.png" src="/malio.png"
alt="Logo" alt="Logo"
class="w-auto" class="w-auto"
@@ -18,49 +30,61 @@
alt="Logo" alt="Logo"
class="h-8 w-8 object-cover object-left" 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> </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 <SidebarLink
to="/" to="/"
icon="mdi:view-dashboard-outline" icon="mdi:view-dashboard-outline"
label="Tableau de bord" label="Tableau de bord"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'" :class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@click="ui.closeMobileSidebar()"
/> />
<SidebarLink <SidebarLink
to="/my-tasks" to="/my-tasks"
icon="mdi:clipboard-check-outline" icon="mdi:clipboard-check-outline"
label="Mes tâches" label="Mes tâches"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/> />
<SidebarLink <SidebarLink
to="/projects" to="/projects"
icon="mdi:folder-outline" icon="mdi:folder-outline"
label="Projets" label="Projets"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/> />
<template v-if="currentProjectId"> <template v-if="currentProjectId">
<SidebarLink <SidebarLink
:to="`/projects/${currentProjectId}`" :to="`/projects/${currentProjectId}`"
icon="mdi:view-column-outline" icon="mdi:view-column-outline"
label="Kanban" label="Kanban"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
sub sub
exact exact
@click="ui.closeMobileSidebar()"
/> />
<SidebarLink <SidebarLink
:to="`/projects/${currentProjectId}/groups`" :to="`/projects/${currentProjectId}/groups`"
icon="mdi:tag-multiple-outline" icon="mdi:tag-multiple-outline"
label="Groupes" label="Groupes"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
sub sub
@click="ui.closeMobileSidebar()"
/> />
<SidebarLink <SidebarLink
:to="`/projects/${currentProjectId}/archives`" :to="`/projects/${currentProjectId}/archives`"
icon="mdi:archive-outline" icon="mdi:archive-outline"
label="Archives" label="Archives"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
sub sub
@click="ui.closeMobileSidebar()"
/> />
</template> </template>
@@ -68,24 +92,26 @@
to="/time-tracking" to="/time-tracking"
icon="mdi:clock-outline" icon="mdi:clock-outline"
label="Suivi de temps" label="Suivi de temps"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/> />
<SidebarLink <SidebarLink
to="/admin" to="/admin"
icon="mdi:cog-outline" icon="mdi:cog-outline"
label="Administration" label="Administration"
:collapsed="ui.sidebarCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/> />
</nav> </nav>
<div class="px-4 py-3"> <div class="px-4 py-3">
<SidebarTimer :collapsed="ui.sidebarCollapsed" /> <SidebarTimer :collapsed="sidebarIsCollapsed" />
</div> </div>
<div class="flex flex-col gap-2 items-center p-4"> <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 <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'" :title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()" @click="ui.toggleSidebar()"
> >
@@ -99,8 +125,8 @@
<div class="h-full flex-1 flex flex-col min-h-0"> <div class="h-full flex-1 flex flex-col min-h-0">
<AppTopNav :user="auth.user" /> <AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-16 pb-24"> <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-12 flex-shrink-0 bg-white" /> <div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/> <slot/>
</main> </main>
</div> </div>
@@ -129,6 +155,17 @@ const ui = useUiStore()
const {version} = useAppVersion() const {version} = useAppVersion()
const route = useRoute() 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 currentProjectId = computed(() => {
const match = route.path.match(/^\/projects\/(\d+)/) const match = route.path.match(/^\/projects\/(\d+)/)
return match ? match[1] : null return match ? match[1] : null
@@ -211,3 +248,14 @@ const handleLogout = async () => {
await navigateTo('/login') await navigateTo('/login')
} }
</script> </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

@@ -1,24 +1,26 @@
<template> <template>
<div> <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"> <div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-6"> <nav class="flex gap-4 sm:gap-6">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
class="px-1 pb-3 text-sm font-semibold transition" class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key :class="activeTab === tab.key
? 'border-b-2 border-primary-500 text-primary-500' ? 'border-b-2 border-primary-500 text-primary-500'
: 'text-neutral-500 hover:text-neutral-700'" : 'text-neutral-500 hover:text-neutral-700'"
@click="activeTab = tab.key" @click="activeTab = tab.key"
> >
{{ tab.label }} {{ tab.label }}
</button> </button>
</nav> </nav>
</div>
</div> </div>
<div class="mt-6"> <div>
<AdminClientTab v-if="activeTab === 'clients'" /> <AdminClientTab v-if="activeTab === 'clients'" />
<AdminStatusTab v-if="activeTab === 'statuses'" /> <AdminStatusTab v-if="activeTab === 'statuses'" />
<AdminEffortTab v-if="activeTab === 'efforts'" /> <AdminEffortTab v-if="activeTab === 'efforts'" />

View File

@@ -492,36 +492,38 @@ const lineOptions = {
<template> <template>
<div> <div>
<h1 class="text-2xl font-bold text-primary-500">{{ $t('dashboard.title') }}</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">{{ $t('dashboard.title') }}</h1>
<!-- Filters --> <!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3"> <div class="mt-4 flex flex-wrap gap-3">
<MalioSelect <MalioSelect
v-model="selectedPeriod" v-model="selectedPeriod"
:options="periodOptions" :options="periodOptions"
:label="$t('dashboard.filters.period')" :label="$t('dashboard.filters.period')"
min-width="!w-48" min-width="!w-48"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-model="selectedProjectId" v-model="selectedProjectId"
:options="projectOptions" :options="projectOptions"
:label="$t('dashboard.filters.project')" :label="$t('dashboard.filters.project')"
:empty-option-label="$t('dashboard.filters.allProjects')" :empty-option-label="$t('dashboard.filters.allProjects')"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-model="selectedUserId" v-model="selectedUserId"
:options="userOptions" :options="userOptions"
:label="$t('dashboard.filters.user')" :label="$t('dashboard.filters.user')"
:empty-option-label="$t('dashboard.filters.allUsers')" :empty-option-label="$t('dashboard.filters.allUsers')"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
</div>
</div> </div>
<!-- Loading --> <!-- Loading -->
@@ -536,7 +538,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400"> <p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.hoursPeriod') }} {{ $t('dashboard.stats.hoursPeriod') }}
</p> </p>
<p class="mt-2 text-3xl font-bold text-neutral-900"> <p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ formatHours(totalHoursThisWeek) }} {{ formatHours(totalHoursThisWeek) }}
</p> </p>
</div> </div>
@@ -544,7 +546,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400"> <p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.myActiveTasks') }} {{ $t('dashboard.stats.myActiveTasks') }}
</p> </p>
<p class="mt-2 text-3xl font-bold text-neutral-900"> <p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ myTasks.length - myTasksDone.length }} {{ myTasks.length - myTasksDone.length }}
</p> </p>
<p class="mt-1 text-xs text-neutral-400"> <p class="mt-1 text-xs text-neutral-400">
@@ -555,7 +557,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400"> <p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.totalTasks') }} {{ $t('dashboard.stats.totalTasks') }}
</p> </p>
<p class="mt-2 text-3xl font-bold text-neutral-900"> <p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ tasks.length }} {{ tasks.length }}
</p> </p>
<p class="mt-1 text-xs text-neutral-400"> <p class="mt-1 text-xs text-neutral-400">
@@ -566,7 +568,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400"> <p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.projects') }} {{ $t('dashboard.stats.projects') }}
</p> </p>
<p class="mt-2 text-3xl font-bold text-neutral-900"> <p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ projects.length }} {{ projects.length }}
</p> </p>
<p class="mt-1 text-xs text-neutral-400"> <p class="mt-1 text-xs text-neutral-400">

View File

@@ -230,103 +230,135 @@ onMounted(() => {
<template> <template>
<div> <div>
<!-- Header --> <!-- Header + Filters -->
<div class="flex items-center justify-between"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1> <div class="flex items-center justify-between gap-3">
<div class="flex gap-1"> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<button <div class="flex gap-1">
class="rounded-lg p-2 transition-colors" <button
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" class="rounded-lg p-2 transition-colors"
:title="$t('myTasks.viewKanban')" :class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
@click="viewMode = 'kanban'" :title="$t('myTasks.viewKanban')"
> @click="viewMode = 'kanban'"
<Icon name="mdi:view-column-outline" size="20" /> >
</button> <Icon name="mdi:view-column-outline" size="20" />
<button </button>
class="rounded-lg p-2 transition-colors" <button
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" class="rounded-lg p-2 transition-colors"
:title="$t('myTasks.viewList')" :class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
@click="viewMode = 'list'" :title="$t('myTasks.viewList')"
> @click="viewMode = 'list'"
<Icon name="mdi:view-list-outline" size="20" /> >
</button> <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>
</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 --> <!-- Kanban View -->
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4"> <div v-if="viewMode === 'kanban'">
<!-- Backlog column (tasks without status) --> <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 <div
v-if="backlogTasks.length > 0" class="mt-8 rounded-lg p-4 transition-colors"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(0)" @dragenter.prevent="onDragEnter(0)"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)" @drop.prevent="onDropBacklog($event)"
> >
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white"> <h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }}) <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard <TaskCard
v-for="task in backlogTasks" v-for="task in backlogTasks"
:key="task.id" :key="task.id"
@@ -334,39 +366,12 @@ onMounted(() => {
@click="openTaskEdit(task)" @click="openTaskEdit(task)"
/> />
</div> </div>
</div> <p
v-if="backlogTasks.length === 0"
<!-- Status columns --> class="py-4 text-center text-xs text-neutral-400"
<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 }}) {{ $t('myTasks.noTasks') }}
</div> </p>
<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>
</div> </div>
@@ -375,12 +380,12 @@ onMounted(() => {
<div <div
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :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)" @click="openTaskEdit(task)"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4> <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 <span
v-if="task.priority" v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white" class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"

View File

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

View File

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

View File

@@ -1,52 +1,55 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-2xl font-bold text-primary-500">{{ project?.name ?? '' }}</h1> <div class="flex items-center justify-between gap-3">
<button <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" <button
@click="openTaskCreate" 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"
+ Ajouter un ticket >
</button> <span class="hidden sm:inline">+ Ajouter un ticket</span>
</div> <span class="sm:hidden">+ Ticket</span>
</button>
</div>
<div class="mt-4 flex flex-wrap gap-3"> <div class="mt-4 flex flex-wrap gap-3">
<MalioSelect <MalioSelect
v-model="selectedGroupId" v-model="selectedGroupId"
:options="groupFilterOptions" :options="groupFilterOptions"
label="Groupe" label="Groupe"
empty-option-label="Tous les groupes" empty-option-label="Tous les groupes"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-model="selectedTagId" v-model="selectedTagId"
:options="tagFilterOptions" :options="tagFilterOptions"
label="Tags" label="Tags"
empty-option-label="Tous les tags" empty-option-label="Tous les tags"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-model="selectedAssigneeId" v-model="selectedAssigneeId"
:options="userFilterOptions" :options="userFilterOptions"
label="User" label="User"
empty-option-label="Tous les users" empty-option-label="Tous les users"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
<MalioSelect <MalioSelect
v-model="selectedStatusId" v-model="selectedStatusId"
:options="statusFilterOptions" :options="statusFilterOptions"
label="Status" label="Status"
empty-option-label="Tous les status" empty-option-label="Tous les status"
min-width="!w-40" min-width="!w-40"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
</div>
</div> </div>
<!-- Kanban --> <!-- Kanban -->
@@ -93,55 +96,14 @@
@dragleave="onDragLeave" @dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)" @drop.prevent="onDropBacklog($event)"
> >
<h2 class="text-lg font-bold text-neutral-900">Backlog</h2> <h2 class="text-lg font-bold text-neutral-900">Backlog ({{ backlogTasks.length }})</h2>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div <TaskCard
v-for="task in backlogTasks" v-for="task in backlogTasks"
:key="task.id" :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" :task="task"
draggable="true"
@dragstart="onBacklogDragStart($event, task)"
@dragend="onBacklogDragEnd"
@click="openTaskEdit(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>
</div> </div>
@@ -307,15 +269,6 @@ function onDrop(event: DragEvent) {
return Number(event.dataTransfer!.getData('text/plain')) 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) { async function onDropStatus(event: DragEvent, status: TaskStatus) {
const taskId = onDrop(event) const taskId = onDrop(event)

View File

@@ -1,28 +1,31 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-2xl font-bold text-primary-500">Projets</h1> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3"> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
<button <div class="flex items-center gap-2 sm:gap-3">
class="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition" <button
:class="showArchived class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
? 'bg-amber-100 text-amber-700 hover:bg-amber-200' :class="showArchived
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'" ? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
@click="toggleArchived" : '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" /> >
{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }} <Icon :name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'" size="18" />
</button> <span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
<button </button>
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500" <button
@click="openCreate" 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"
+ Ajouter un projet >
</button> <span class="hidden sm:inline">+ Ajouter un projet</span>
<span class="sm:hidden">+ Projet</span>
</button>
</div>
</div> </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 <div
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"

View File

@@ -1,17 +1,18 @@
<template> <template>
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<div ref="pageHeaderEl" class="flex-shrink-0 pb-4"> <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"> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-primary-500">Suivi des temps</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
<button <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()" @click="openCreateDrawer()"
> >
+ Ajouter une Activité <span class="hidden sm:inline">+ Ajouter une Activité</span>
<span class="sm:hidden">+ Activité</span>
</button> </button>
</div> </div>
<div class="relative z-30 mt-4 flex items-center gap-4"> <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"> <h2 class="text-lg font-bold text-orange-500">
{{ currentMonthLabel }} {{ currentMonthLabel }}
</h2> </h2>
@@ -37,7 +38,7 @@
<MalioSelect <MalioSelect
v-model="selectedUserId" v-model="selectedUserId"
:options="userOptions" :options="userOptions"
min-width="!w-44" min-width="!w-36 sm:!w-44"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
label="User" label="User"
@@ -49,7 +50,7 @@
:options="projectOptions" :options="projectOptions"
empty-option-label="Tous" empty-option-label="Tous"
label="Projet" label="Projet"
min-width="!w-44" min-width="!w-36 sm:!w-44"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />
@@ -59,7 +60,7 @@
:options="tagOptions" :options="tagOptions"
empty-option-label="Tous" empty-option-label="Tous"
label="Tag" label="Tag"
min-width="!w-44" min-width="!w-36 sm:!w-44"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
/> />

View File

@@ -1,5 +1,6 @@
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const sidebarOpen = ref(false)
if (import.meta.client) { if (import.meta.client) {
const saved = localStorage.getItem('ui-sidebar-collapsed') const saved = localStorage.getItem('ui-sidebar-collapsed')
@@ -18,5 +19,13 @@ export const useUiStore = defineStore('ui', () => {
sidebarCollapsed.value = !sidebarCollapsed.value 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

@@ -13,6 +13,7 @@ use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final readonly class GiteaApiService final readonly class GiteaApiService
{ {
@@ -203,7 +204,11 @@ final readonly class GiteaApiService
throw new GiteaApiException('Gitea token is not set.'); 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 private function assertProjectHasRepo(Project $project): void

View File

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