Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8befb216aa | |||
|
|
0113c08a60 | ||
|
|
c176511d97 | ||
|
|
64de971872 | ||
|
|
3dcc5c21a2 | ||
|
|
47768c0f02 | ||
|
|
b278b8a23a | ||
|
|
4074457499 | ||
|
|
b29b4d304d | ||
|
|
dd9db93751 | ||
|
|
3e2f3b3cf8 | ||
|
|
5bf768bc02 | ||
|
|
77c7ceb064 | ||
|
|
ac36eeba36 | ||
|
|
005b731a97 |
@@ -51,7 +51,6 @@ jobs:
|
|||||||
migrations \
|
migrations \
|
||||||
public \
|
public \
|
||||||
src \
|
src \
|
||||||
templates \
|
|
||||||
vendor \
|
vendor \
|
||||||
composer.json \
|
composer.json \
|
||||||
composer.lock \
|
composer.lock \
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.2.6'
|
app.version: '0.3.1'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="blockEl"
|
ref="blockEl"
|
||||||
class="absolute z-10 cursor-pointer rounded-md text-xs text-white shadow-sm select-none"
|
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||||
:style="blockStyle"
|
:style="blockStyle"
|
||||||
:class="{ 'opacity-40': isDragSource }"
|
:class="{ 'opacity-40': isDragSource }"
|
||||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||||
@@ -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,10 +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 bgColor = props.entry.project?.color ?? '#94a3b8'
|
|
||||||
|
|
||||||
const col = props.columnIndex ?? 0
|
const col = props.columnIndex ?? 0
|
||||||
const total = props.totalColumns ?? 1
|
const total = props.totalColumns ?? 1
|
||||||
@@ -127,13 +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: bgColor,
|
|
||||||
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 ---
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteProjectModal.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
<div
|
<div
|
||||||
v-for="project in projects"
|
v-for="project in projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="cursor-pointer rounded-[6px] border border-neutral-200 bg-tertiary-500 p-4 shadow-sm transition hover:shadow-md"
|
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
|
||||||
:class="{ 'opacity-60': project.archived }"
|
:class="{ 'opacity-60': project.archived }"
|
||||||
|
:style="projectCardStyle(project.color)"
|
||||||
@click="navigateTo(`/projects/${project.id}`)"
|
@click="navigateTo(`/projects/${project.id}`)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -80,6 +81,17 @@ import { useClientService } from '~/services/clients'
|
|||||||
|
|
||||||
useHead({ title: 'Projets' })
|
useHead({ title: 'Projets' })
|
||||||
|
|
||||||
|
function projectCardStyle(color: string | null) {
|
||||||
|
const hex = (color || '#222783').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)
|
||||||
|
return {
|
||||||
|
borderRadius: '16px',
|
||||||
|
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const clientService = useClientService()
|
const clientService = useClientService()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
BIN
frontend/public/LOGO_CARRE.png
Normal file
BIN
frontend/public/LOGO_CARRE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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 = {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user