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
* 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

@@ -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,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

@@ -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: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 flex-1 flex-col overflow-y-auto bg-white px-16 pb-24">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-12 flex-shrink-0 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

@@ -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

@@ -492,36 +492,38 @@ const lineOptions = {
<template>
<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 -->
<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"
/>
<!-- 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 -->
@@ -536,7 +538,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.hoursPeriod') }}
</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) }}
</p>
</div>
@@ -544,7 +546,7 @@ const lineOptions = {
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.myActiveTasks') }}
</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 }}
</p>
<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">
{{ $t('dashboard.stats.totalTasks') }}
</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 }}
</p>
<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">
{{ $t('dashboard.stats.projects') }}
</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 }}
</p>
<p class="mt-1 text-xs text-neutral-400">

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,28 +1,31 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">Projets</h1>
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition"
: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" />
{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}
</button>
<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"

View File

@@ -1,17 +1,18 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div ref="pageHeaderEl" class="flex-shrink-0 pb-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">Suivi des temps</h1>
<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">
<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>
@@ -37,7 +38,7 @@
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
min-width="!w-44"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
label="User"
@@ -49,7 +50,7 @@
:options="projectOptions"
empty-option-label="Tous"
label="Projet"
min-width="!w-44"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>
@@ -59,7 +60,7 @@
:options="tagOptions"
empty-option-label="Tous"
label="Tag"
min-width="!w-44"
min-width="!w-36 sm:!w-44"
text-field="text-sm"
text-value="text-sm"
/>

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

@@ -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.');
}
}
}