Compare commits

...

9 Commits

Author SHA1 Message Date
Matthieu
0113c08a60 chore : bump version to v0.3.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:21 +01:00
Matthieu
c176511d97 feat(ui) : add app title with swap button in top nav bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:13:12 +01:00
Matthieu
64de971872 feat(ui) : improve textarea description fields with vertical resize
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:11:00 +01:00
Matthieu
3dcc5c21a2 chore : bump version to v0.3.0
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:31 +01:00
Matthieu
47768c0f02 feat(time-tracking) : redesign calendar blocks and view mode switcher
Restyle time entry blocks with title on top, project below, tags
bottom-left, duration bottom-right. Checkerboard pattern for entries
without project. Pill-style view mode switcher. Link DateFilter mode
to main view mode and remove redundant toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
Matthieu
b278b8a23a feat(ui) : improve sidebar collapse button, logo and top nav
Move sidebar collapse toggle to mid-height floating circle button,
use LOGO_CARRE.png when collapsed, make timer button circular when
collapsed, reduce app bar height to 60px max.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:21 +01:00
gitea-actions
4074457499 chore: bump version to v0.2.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-18 10:08:03 +00:00
Matthieu
b29b4d304d fix(user) : clear allowedProjects when removing ROLE_CLIENT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Prevents sending /api/projects/undefined when saving a user after
removing client role. Also auto-clears client and projects when
ROLE_CLIENT checkbox is unchecked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:51 +01:00
Matthieu
dd9db93751 feat(project) : add delete button for empty projects with confirmation modal
Adds taskCount virtual field on Project entity, delete button in ProjectDrawer
(visible only when taskCount === 0), and a reusable ConfirmDeleteProjectModal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:41 +01:00
19 changed files with 254 additions and 116 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.2.9' app.version: '0.3.1'

View File

@@ -73,6 +73,7 @@
v-model="editForm.description" v-model="editForm.description"
rows="5" rows="5"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
style="resize: vertical; min-height: 140px; max-height: 500px"
/> />
</div> </div>

View File

@@ -64,7 +64,7 @@
</div> </div>
</form> </form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4"> <div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button <button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600" class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting" :disabled="isSubmitting"
@@ -73,7 +73,21 @@
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" /> <Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }} {{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button> </button>
<button
v-if="project.taskCount === 0"
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
<Icon name="mdi:delete-outline" size="18" />
{{ $t('common.delete') }}
</button>
</div> </div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer> </AppDrawer>
</template> </template>
@@ -104,6 +118,7 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
} }
}) })
const { create, update } = useProjectService() const { create, update, remove } = useProjectService()
async function handleSubmit() { async function handleSubmit() {
touched.name = true touched.name = true
@@ -213,6 +228,19 @@ async function handleSubmit() {
} }
} }
async function handleDelete() {
if (!props.project) return
isSubmitting.value = true
try {
await remove(props.project.id)
emit('saved')
isOpen.value = false
} finally {
confirmDeleteOpen.value = false
isSubmitting.value = false
}
}
async function handleArchiveToggle() { async function handleArchiveToggle() {
if (!props.project) return if (!props.project) return
isSubmitting.value = true isSubmitting.value = true

View File

@@ -156,7 +156,12 @@
<MalioInputTextArea <MalioInputTextArea
v-model="form.description" v-model="form.description"
label="Description" label="Description"
:size="3" :size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/> />
</div> </div>

View File

@@ -17,38 +17,33 @@
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" /> <div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div> </div>
<div class="px-1.5 py-0.5 h-full overflow-hidden"> <div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Full display: title + project + type dot + duration --> <!-- Top: title + project -->
<template v-if="sizeLevel >= 3"> <div class="min-w-0">
<div class="flex items-center gap-1"> <div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div> <div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span> </div>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div> <!-- Spacer -->
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden"> <div class="flex-1" />
<!-- Bottom: tags left, duration right -->
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
<div v-if="entry.tags.length" class="flex items-center gap-1 overflow-hidden min-w-0">
<span <span
v-for="tag in entry.tags" v-for="tag in entry.tags"
:key="tag.id" :key="tag.id"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90" class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white"
:style="{ backgroundColor: tag.color }"
> >
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.label }} {{ tag.label }}
</span> </span>
</div> </div>
</template> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
<!-- Medium: title + duration --> <div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
<template v-else-if="sizeLevel === 2"> <span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div> </div>
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
</template>
<!-- Small: title only -->
<template v-else-if="sizeLevel === 1">
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
</template>
<!-- Tiny: just a colored bar, no text -->
</div> </div>
<!-- Resize handle bottom (outside block) --> <!-- Resize handle bottom (outside block) -->
@@ -116,13 +111,11 @@ const sizeLevel = computed(() => {
return 0 return 0
}) })
const hasProject = computed(() => !!props.entry.project)
const blockStyle = computed(() => { const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const hex = (props.entry.project?.color ?? '#94a3b8').replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
const col = props.columnIndex ?? 0 const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1 const total = props.totalColumns ?? 1
@@ -130,14 +123,28 @@ const blockStyle = computed(() => {
const leftPercent = (col / total) * 100 const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100 const widthPercent = (1 / total) * 100
return { const base: Record<string, string> = {
top: `${topPx}px`, top: `${topPx}px`,
height: `${heightPx.value}px`, height: `${heightPx.value}px`,
backgroundColor: `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`,
color: `rgb(${r}, ${g}, ${b})`,
left: `calc(${leftPercent}% + ${gapPx}px)`, left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}px)`, width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
} }
if (hasProject.value) {
const hex = props.entry.project!.color.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
base.color = `rgb(${r}, ${g}, ${b})`
} else {
base.backgroundColor = '#e5e7eb'
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
base.backgroundSize = '12px 12px'
base.color = '#6b7280'
}
return base
}) })
// --- Click / Drag detection --- // --- Click / Drag detection ---

View File

@@ -1,27 +1,27 @@
<template> <template>
<div ref="calendarEl" class="relative flex h-full flex-col 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 --> <!-- Grid body with sticky header -->
<div <div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg" <!-- Day headers (sticky inside scroll container) -->
> <div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
<div class="w-16 shrink-0 border-r border-neutral-200" /> <div class="w-16 shrink-0 border-r border-neutral-200" />
<div <div
v-for="day in days" v-for="day in days"
:key="day.dateStr" :key="'header-' + day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center" class="flex-1 border-r border-neutral-100 py-2 text-center"
> >
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'"> <div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }} {{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div> </div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div> </div>
</div>
<!-- Grid body --> <!-- Columns -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto"> <div class="relative flex">
<!-- Hour labels --> <!-- Hour labels -->
<div class="w-16 shrink-0"> <div class="w-16 shrink-0">
<div <div
@@ -134,7 +134,8 @@
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div> <div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div> </div>
</div> </div>
</div> </div><!-- end columns flex -->
</div><!-- end gridBodyEl -->
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<header class="border-b border-neutral-200 bg-primary-500 p-3 text-white sm:p-5"> <header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
<div class="flex h-full items-center justify-between"> <div class="flex h-full items-center justify-between">
<button <button
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden" class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
@@ -7,6 +7,17 @@
> >
<Icon name="mdi:menu" size="24" /> <Icon name="mdi:menu" size="24" />
</button> </button>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
<button
type="button"
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
@click="toggleTitle"
>
<Icon name="mdi:swap-horizontal" size="18" />
</button>
</div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8"> <div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell /> <NotificationBell />
<div class="group relative flex gap-2 sm:gap-4"> <div class="group relative flex gap-2 sm:gap-4">
@@ -45,6 +56,13 @@ defineProps<{
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
function toggleTitle() {
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
localStorage.setItem('appTitle', appTitle.value)
}
async function handleLogout() { async function handleLogout() {
await auth.logout() await auth.logout()
await navigateTo('/login') await navigateTo('/login')

View File

@@ -0,0 +1,58 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('projects.deleteConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('projects.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
{{ $t('common.delete') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -15,22 +15,6 @@
> >
<template #trigger> <template #trigger>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('day')"
>
{{ t('common.day') }}
</button>
<button
class="px-2 py-[7px] text-xs font-medium transition"
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
@click.stop="switchMode('week')"
>
{{ t('common.weekShort') }}
</button>
</div>
<div class="relative cursor-pointer"> <div class="relative cursor-pointer">
<input <input
:value="displayValue" :value="displayValue"
@@ -85,6 +69,7 @@ const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue?: Date | [Date, Date] | null modelValue?: Date | [Date, Date] | null
placeholder?: string placeholder?: string
pickerMode?: 'day' | 'week'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -92,7 +77,7 @@ const emit = defineEmits<{
}>() }>()
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null) const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = ref<'day' | 'week'>('week') const mode = computed(() => props.pickerMode ?? 'week')
const internalValue = ref<Date | Date[] | null>(null) const internalValue = ref<Date | Date[] | null>(null)
const displayValue = computed(() => { const displayValue = computed(() => {
@@ -133,13 +118,6 @@ function formatShortDate(d: Date): string {
return `${day}/${month}` return `${day}/${month}`
} }
function switchMode(newMode: 'day' | 'week') {
if (mode.value === newMode) return
mode.value = newMode
internalValue.value = null
emit('update:modelValue', null)
}
function onUpdate(value: Date | Date[] | null) { function onUpdate(value: Date | Date[] | null) {
if (!value) { if (!value) {
emit('update:modelValue', null) emit('update:modelValue', null)
@@ -163,7 +141,6 @@ function onClear() {
} }
function selectToday() { function selectToday() {
mode.value = 'day'
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
internalValue.value = today internalValue.value = today
@@ -171,7 +148,6 @@ function selectToday() {
} }
function selectThisWeek() { function selectThisWeek() {
mode.value = 'week'
const now = new Date() const now = new Date()
const day = now.getDay() const day = now.getDay()
const monday = new Date(now) const monday = new Date(now)

View File

@@ -1,11 +1,11 @@
<template> <template>
<button <button
class="flex w-full items-center justify-center gap-2 rounded-md py-2 text-sm font-semibold text-white transition" class="flex items-center justify-center gap-2 text-sm font-semibold text-white transition"
:class="[ :class="[
timerStore.isRunning timerStore.isRunning
? 'bg-[#F18619] hover:bg-[#d97314]' ? 'bg-[#F18619] hover:bg-[#d97314]'
: 'bg-primary-500 hover:bg-primary-600', : 'bg-primary-500 hover:bg-primary-600',
collapsed ? 'px-2' : 'px-4' collapsed ? 'mx-auto h-10 w-10 rounded-full' : 'w-full rounded-md px-4 py-2'
]" ]"
: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()"

View File

@@ -148,6 +148,13 @@ function onClientChange(value: number | null) {
} }
} }
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
if (open) { if (open) {
if (props.item) { if (props.item) {
@@ -189,7 +196,9 @@ async function handleSubmit() {
username: form.username.trim(), username: form.username.trim(),
roles: form.roles, roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null, client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`), allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
} }
if (form.password) { if (form.password) {
payload.plainPassword = form.password payload.plainPassword = form.password

View File

@@ -39,7 +39,10 @@
"noArchivedProjects": "Aucun projet archivé.", "noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet", "addProject": "Ajouter un projet",
"addProjectShort": "Projet", "addProjectShort": "Projet",
"editProject": "Modifier un projet" "editProject": "Modifier un projet",
"deleteConfirmTitle": "Supprimer le projet",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
}, },
"taskStatuses": { "taskStatuses": {
"created": "Statut créé avec succès.", "created": "Statut créé avec succès.",

View File

@@ -17,7 +17,7 @@
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full', ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]" ]"
> >
<div class="flex items-center justify-between overflow-hidden" :class="sidebarIsCollapsed ? 'p-2 justify-center' : ''"> <div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img <img
v-if="!sidebarIsCollapsed" v-if="!sidebarIsCollapsed"
src="/malio.png" src="/malio.png"
@@ -26,9 +26,9 @@
/> />
<img <img
v-else v-else
src="/malio.png" src="/LOGO_CARRE.png"
alt="Logo" alt="Logo"
class="h-8 w-8 object-cover object-left" class="w-[46px] h-[55px]"
/> />
<button <button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden" class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@@ -90,7 +90,7 @@
</template> </template>
<SidebarLink <SidebarLink
to="/time-tracking" to="/time-tracking"
icon="mdi:clock-outline" icon="mdi:calendar-edit-outline"
label="Suivi de temps" label="Suivi de temps"
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
@@ -108,19 +108,21 @@
<SidebarTimer :collapsed="sidebarIsCollapsed" /> <SidebarTimer :collapsed="sidebarIsCollapsed" />
</div> </div>
<div class="flex flex-col gap-2 items-center p-4"> <div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p> <p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
<button
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()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="20"
/>
</button>
</div> </div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside> </aside>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">

View File

@@ -41,6 +41,11 @@
v-model="form.description" v-model="form.description"
:label="$t('clientTicket.description')" :label="$t('clientTicket.description')"
:size="5" :size="5"
resize="vertical"
:min-resize-height="140"
:max-resize-height="500"
min-resize-width="100%"
max-resize-width="100%"
/> />
</div> </div>

View File

@@ -17,20 +17,26 @@
{{ currentMonthLabel }} {{ currentMonthLabel }}
</h2> </h2>
<div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200"> <div class="flex shrink-0 items-center gap-3">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev"> <button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" /> <Icon name="mdi:chevron-left" size="20" />
</button> </button>
<button
v-for="mode in (['week', 'day', 'list'] as const)" <div class="flex items-center rounded-full bg-neutral-100 p-1">
:key="mode" <button
class="px-3 py-1 text-sm font-semibold transition" v-for="mode in (['week', 'day', 'list'] as const)"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'" :key="mode"
@click="viewMode = mode" class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
> :class="viewMode === mode
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }} ? 'bg-primary-500 text-white shadow-sm'
</button> : 'text-neutral-500 hover:text-neutral-700'"
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext"> @click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
</button>
</div>
<button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" /> <Icon name="mdi:chevron-right" size="20" />
</button> </button>
</div> </div>
@@ -71,7 +77,7 @@
/> />
</div> </div>
<DateFilter v-model="selectedDateFilter" /> <DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
</div> </div>
</div> </div>
@@ -334,6 +340,7 @@ onMounted(async () => {
}) })
watch(viewMode, () => { watch(viewMode, () => {
selectedDateFilter.value = null
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value) startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
loadEntries() loadEntries()
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -13,6 +13,7 @@ export type Project = {
bookstackShelfId: number | null bookstackShelfId: number | null
bookstackShelfName: string | null bookstackShelfName: string | null
archived: boolean archived: boolean
taskCount: number
} }
export type ProjectWrite = { export type ProjectWrite = {

View File

@@ -13,6 +13,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -87,6 +89,15 @@ class Project
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private bool $archived = false; private bool $archived = false;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -216,4 +227,10 @@ class Project
return $this; return $this;
} }
#[Groups(['project:read'])]
public function getTaskCount(): int
{
return $this->tasks->count();
}
} }

View File

@@ -82,7 +82,7 @@ class Task
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private ?TaskGroup $group = null; private ?TaskGroup $group = null;
#[ORM\ManyToOne(targetEntity: Project::class)] #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private ?Project $project = null; private ?Project $project = null;