Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1219f3e73e | ||
|
|
ec35a1b2aa | ||
|
|
0113c08a60 | ||
|
|
c176511d97 | ||
|
|
64de971872 |
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.0'
|
||||
app.version: '0.3.1'
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
v-model="editForm.description"
|
||||
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"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
131
frontend/components/task/TaskBulkActions.vue
Normal file
131
frontend/components/task/TaskBulkActions.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click="emit('bulk-delete')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
</script>
|
||||
@@ -9,7 +9,17 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
@@ -63,9 +73,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
showProjectColor?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
|
||||
108
frontend/components/task/TaskListItem.vue
Normal file
108
frontend/components/task/TaskListItem.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
@@ -156,7 +156,12 @@
|
||||
<MalioInputTextArea
|
||||
v-model="form.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>
|
||||
|
||||
|
||||
@@ -29,15 +29,21 @@
|
||||
|
||||
<!-- 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">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
@@ -111,6 +117,17 @@ const sizeLevel = computed(() => {
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
|
||||
@@ -105,12 +105,22 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
@@ -231,6 +241,26 @@ watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-4 rounded-lg border border-neutral-200 bg-white px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
@click="emit('editEntry', entry)"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
@@ -18,14 +18,14 @@
|
||||
|
||||
<!-- Main info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</span>
|
||||
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
|
||||
@@ -201,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
|
||||
// Scroll to current hour on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!calendarEl.value) return
|
||||
const scrollParent = getScrollParent()
|
||||
if (!scrollParent) return
|
||||
if (!gridBodyEl.value) return
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const calendarTop = calendarEl.value.offsetTop
|
||||
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
||||
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
||||
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</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">
|
||||
<NotificationBell />
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
@@ -45,6 +56,13 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
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() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="date-filter">
|
||||
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
|
||||
<VueDatePicker
|
||||
ref="datepicker"
|
||||
v-model="internalValue"
|
||||
@@ -14,29 +14,11 @@
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="relative cursor-pointer">
|
||||
<input
|
||||
:value="displayValue"
|
||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||
:placeholder="t('common.dateFilter')"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
v-if="internalValue"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="mdi:calendar"
|
||||
size="16"
|
||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
|
||||
>
|
||||
<Icon name="mdi:calendar-blank" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #action-buttons>
|
||||
|
||||
@@ -51,6 +51,9 @@ const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
@@ -228,6 +231,52 @@ async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
@@ -247,24 +296,16 @@ onMounted(() => {
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</button>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 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="18" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md p-1.5 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="18" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,6 +392,7 @@ onMounted(() => {
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
@@ -379,6 +421,7 @@ onMounted(() => {
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
@@ -392,57 +435,31 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="tasks.length"
|
||||
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
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"
|
||||
:task="task"
|
||||
show-project-color
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@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 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"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
title="Vue liste"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
|
||||
title="Paramètres du projet"
|
||||
@@ -58,11 +68,29 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
@@ -100,6 +128,7 @@
|
||||
|
||||
<!-- Backlog -->
|
||||
<div
|
||||
v-if="viewMode === 'kanban'"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||
@dragover.prevent
|
||||
@@ -118,6 +147,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="filteredTasks.length"
|
||||
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
@toggle-all="toggleSelectAll(filteredTasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="filteredTasks.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
@@ -191,6 +253,10 @@ const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
@@ -213,6 +279,14 @@ const statusFilterOptions = computed(() =>
|
||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const priorityFilterOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortFilterOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
@@ -227,6 +301,12 @@ const filteredTasks = computed(() => {
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
result = result.filter(t => t.effort?.id === selectedEffortId.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -311,6 +391,52 @@ async function onDropBacklog(event: DragEvent) {
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
@@ -13,34 +13,33 @@
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<button class="rounded-full p-1 text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||
<Icon name="mdi:chevron-left" size="20" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@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">
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||
<Icon name="mdi:chevron-right" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
@@ -76,8 +75,6 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user