Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8b899f7c4 | ||
|
|
766fddd417 | ||
|
|
1219f3e73e | ||
|
|
ec35a1b2aa |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.1'
|
app.version: '0.3.4'
|
||||||
|
|||||||
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="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-1">
|
<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
|
<Icon
|
||||||
v-if="task.clientTicket"
|
v-if="task.clientTicket"
|
||||||
name="heroicons:user-circle"
|
name="heroicons:user-circle"
|
||||||
@@ -63,9 +73,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
}>()
|
showProjectColor?: boolean
|
||||||
|
}>(), {
|
||||||
|
showProjectColor: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click'): void
|
(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>
|
||||||
@@ -29,15 +29,21 @@
|
|||||||
|
|
||||||
<!-- Bottom: tags left, duration right -->
|
<!-- Bottom: tags left, duration right -->
|
||||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
<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
|
<span
|
||||||
v-for="tag in entry.tags"
|
v-for="tag in visibleTags"
|
||||||
:key="tag.id"
|
: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 }"
|
:style="{ backgroundColor: tag.color }"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +117,17 @@ const sizeLevel = computed(() => {
|
|||||||
return 0
|
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 hasProject = computed(() => !!props.entry.project)
|
||||||
|
|
||||||
const blockStyle = computed(() => {
|
const blockStyle = computed(() => {
|
||||||
|
|||||||
@@ -105,12 +105,22 @@
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
type="submit"
|
<button
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
v-if="isEditing"
|
||||||
>
|
type="button"
|
||||||
Enregistrer
|
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||||
</button>
|
@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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</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() {
|
async function onDelete() {
|
||||||
if (!props.entry) return
|
if (!props.entry) return
|
||||||
const { remove } = useTimeEntryService()
|
const { remove } = useTimeEntryService()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="entry in sortedEntries"
|
v-for="entry in sortedEntries"
|
||||||
:key="entry.id"
|
: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)"
|
@click="emit('editEntry', entry)"
|
||||||
>
|
>
|
||||||
<!-- Color bar -->
|
<!-- Color bar -->
|
||||||
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
<!-- Main info -->
|
<!-- Main info -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||||
<span class="truncate text-sm font-semibold text-neutral-900">
|
{{ entry.title || $t('common.untitled') }}
|
||||||
{{ entry.title || $t('common.untitled') }}
|
</div>
|
||||||
</span>
|
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="tag in entry.tags"
|
v-for="tag in entry.tags"
|
||||||
:key="tag.id"
|
: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 }"
|
:style="{ backgroundColor: tag.color }"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
|
|||||||
@@ -201,14 +201,11 @@ function getScrollParent(): HTMLElement | null {
|
|||||||
// Scroll to current hour on mount
|
// Scroll to current hour on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!calendarEl.value) return
|
if (!gridBodyEl.value) return
|
||||||
const scrollParent = getScrollParent()
|
|
||||||
if (!scrollParent) return
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
const calendarTop = calendarEl.value.offsetTop
|
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||||
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
|
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||||
scrollParent.scrollTop = Math.max(0, scrollTarget)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="date-filter">
|
<div class="date-filter inline-flex h-8 items-center [&>.dp__main]:!inline-flex [&>.dp__main]:!items-center">
|
||||||
<VueDatePicker
|
<VueDatePicker
|
||||||
ref="datepicker"
|
ref="datepicker"
|
||||||
v-model="internalValue"
|
v-model="internalValue"
|
||||||
@@ -14,29 +14,11 @@
|
|||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="flex items-center gap-1">
|
<button
|
||||||
<div class="relative cursor-pointer">
|
class="relative flex h-8 w-8 items-center justify-center rounded-full text-orange-500 transition hover:bg-orange-50"
|
||||||
<input
|
>
|
||||||
:value="displayValue"
|
<Icon name="mdi:calendar-blank" size="20" />
|
||||||
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"
|
</button>
|
||||||
: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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #action-buttons>
|
<template #action-buttons>
|
||||||
|
|||||||
@@ -86,7 +86,14 @@
|
|||||||
sub
|
sub
|
||||||
@click="ui.closeMobileSidebar()"
|
@click="ui.closeMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||||
|
icon="mdi:ticket-outline"
|
||||||
|
label="Tickets client"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
sub
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/time-tracking"
|
to="/time-tracking"
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
<AdminUserTab v-if="activeTab === 'users'" />
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
|
||||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +44,6 @@ const tabs = [
|
|||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
{ key: 'users', label: 'Utilisateurs' },
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
{ key: 'client-tickets', label: 'Tickets client' },
|
|
||||||
{ key: 'gitea', label: 'Gitea' },
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
{ key: 'bookstack', label: 'BookStack' },
|
{ key: 'bookstack', label: 'BookStack' },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
|||||||
// View toggle
|
// View toggle
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
const taskModalOpen = ref(false)
|
const taskModalOpen = ref(false)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
@@ -228,6 +231,52 @@ async function onSaved() {
|
|||||||
await loadTasks()
|
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(() => {
|
onMounted(() => {
|
||||||
loadAll()
|
loadAll()
|
||||||
})
|
})
|
||||||
@@ -247,24 +296,16 @@ onMounted(() => {
|
|||||||
<Icon name="mdi:plus" size="18" />
|
<Icon name="mdi:plus" size="18" />
|
||||||
{{ $t('myTasks.createTask') }}
|
{{ $t('myTasks.createTask') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex gap-1">
|
<button
|
||||||
<button
|
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
|
||||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
:class="viewMode === 'list'
|
||||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
? 'border-primary-500 bg-primary-500 text-white'
|
||||||
:title="$t('myTasks.viewKanban')"
|
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||||
@click="viewMode = 'kanban'"
|
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||||
>
|
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||||
<Icon name="mdi:view-column-outline" size="18" />
|
>
|
||||||
</button>
|
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,6 +392,7 @@ onMounted(() => {
|
|||||||
v-for="task in tasksByStatus(status.id)"
|
v-for="task in tasksByStatus(status.id)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
show-project-color
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
@@ -379,6 +421,7 @@ onMounted(() => {
|
|||||||
v-for="task in backlogTasks"
|
v-for="task in backlogTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
show-project-color
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,57 +435,31 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-if="viewMode === 'list'" class="mt-6">
|
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||||
<div
|
<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"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
: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)"
|
@click="openTaskEdit(task)"
|
||||||
>
|
@toggle-select="toggleTaskSelect"
|
||||||
<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>
|
|
||||||
<p
|
<p
|
||||||
v-if="tasks.length === 0 && !isLoading"
|
v-if="tasks.length === 0 && !isLoading"
|
||||||
class="py-8 text-center text-sm text-neutral-400"
|
class="py-8 text-center text-sm text-neutral-400"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
@@ -45,12 +46,21 @@
|
|||||||
@cancel="selectedFile = null"
|
@cancel="selectedFile = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const isClientOnly = computed(() =>
|
||||||
|
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
|
)
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
})
|
||||||
const { upload, remove } = useAvatarService()
|
const { upload, remove } = useAvatarService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
|
|||||||
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
265
frontend/pages/projects/[id]/client-tickets.vue
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||||
|
Tickets client
|
||||||
|
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
v-model="filterStatus"
|
||||||
|
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option :value="null">Tous les statuts</option>
|
||||||
|
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||||
|
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||||
|
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||||
|
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||||
|
{{ $t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||||
|
{{ $t('clientTicket.noTickets') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="ticket in filteredTickets"
|
||||||
|
:key="ticket.id"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-white"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||||
|
@click="toggleExpand(ticket.id)"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:class="typeBadgeClass(ticket.type)"
|
||||||
|
>
|
||||||
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||||
|
:class="statusBadgeClass(ticket.status)"
|
||||||
|
>
|
||||||
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||||
|
:title="$t('clientTicket.changeStatus')"
|
||||||
|
@click.stop="openStatusChange(ticket)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:swap-horizontal" size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||||
|
title="Supprimer"
|
||||||
|
@click.stop="onDelete(ticket)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="18" />
|
||||||
|
</button>
|
||||||
|
<Icon
|
||||||
|
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||||
|
size="20"
|
||||||
|
class="text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded details -->
|
||||||
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||||
|
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||||
|
<div v-if="ticket.url" class="mt-2">
|
||||||
|
<a
|
||||||
|
:href="ticket.url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||||
|
>
|
||||||
|
{{ ticket.url }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||||
|
{{ ticket.statusComment }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status change modal -->
|
||||||
|
<Teleport v-if="statusModalOpen" to="body">
|
||||||
|
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="statusModalOpen = false"
|
||||||
|
/>
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||||
|
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||||
|
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||||
|
<select
|
||||||
|
v-model="newStatus"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option :value="null" disabled>—</option>
|
||||||
|
<option
|
||||||
|
v-for="s in availableStatusTransitions"
|
||||||
|
:key="s.value"
|
||||||
|
:value="s.value"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="statusComment"
|
||||||
|
:label="$t('clientTicket.statusComment')"
|
||||||
|
:size="3"
|
||||||
|
/>
|
||||||
|
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ $t('clientTicket.rejectionRequired') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
|
@click="statusModalOpen = false"
|
||||||
|
>
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isUpdatingStatus"
|
||||||
|
@click="confirmStatusChange"
|
||||||
|
>
|
||||||
|
Confirmer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import { useClientTicketService } from '~/services/client-tickets'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const projectId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
useHead({ title: 'Tickets client' })
|
||||||
|
|
||||||
|
const clientTicketService = useClientTicketService()
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||||
|
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
const tickets = ref<ClientTicket[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const filterStatus = ref<string | null>(null)
|
||||||
|
const expandedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const filteredTickets = computed(() => {
|
||||||
|
if (!filterStatus.value) return tickets.value
|
||||||
|
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Status change
|
||||||
|
const statusModalOpen = ref(false)
|
||||||
|
const statusTarget = ref<ClientTicket | null>(null)
|
||||||
|
const newStatus = ref<string | null>(null)
|
||||||
|
const statusComment = ref('')
|
||||||
|
const rejectionError = ref(false)
|
||||||
|
const isUpdatingStatus = ref(false)
|
||||||
|
|
||||||
|
const availableStatusTransitions = computed(() => {
|
||||||
|
if (!statusTarget.value) return []
|
||||||
|
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleExpand(id: number) {
|
||||||
|
expandedId.value = expandedId.value === id ? null : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusChange(ticket: ClientTicket) {
|
||||||
|
statusTarget.value = ticket
|
||||||
|
newStatus.value = null
|
||||||
|
statusComment.value = ''
|
||||||
|
rejectionError.value = false
|
||||||
|
statusModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmStatusChange() {
|
||||||
|
if (!statusTarget.value || !newStatus.value) return
|
||||||
|
|
||||||
|
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||||
|
rejectionError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingStatus.value = true
|
||||||
|
try {
|
||||||
|
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||||
|
status: newStatus.value as ClientTicketStatus,
|
||||||
|
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||||
|
})
|
||||||
|
statusModalOpen.value = false
|
||||||
|
await loadTickets()
|
||||||
|
} finally {
|
||||||
|
isUpdatingStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(ticket: ClientTicket) {
|
||||||
|
await clientTicketService.remove(ticket.id)
|
||||||
|
await loadTickets()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const [p, t] = await Promise.all([
|
||||||
|
projectService.getById(projectId.value),
|
||||||
|
clientTicketService.getAll({ project: projectId.value }),
|
||||||
|
])
|
||||||
|
project.value = p
|
||||||
|
tickets.value = t
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -11,6 +11,16 @@
|
|||||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||||
<span class="sm:hidden">+ Ticket</span>
|
<span class="sm:hidden">+ Ticket</span>
|
||||||
</button>
|
</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
|
<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"
|
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"
|
title="Paramètres du projet"
|
||||||
@@ -58,11 +68,29 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kanban -->
|
<!-- 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
|
<div
|
||||||
v-for="status in statuses"
|
v-for="status in statuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
@@ -100,6 +128,7 @@
|
|||||||
|
|
||||||
<!-- Backlog -->
|
<!-- Backlog -->
|
||||||
<div
|
<div
|
||||||
|
v-if="viewMode === 'kanban'"
|
||||||
class="mt-8 rounded-lg p-4 transition-colors"
|
class="mt-8 rounded-lg p-4 transition-colors"
|
||||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@@ -118,6 +147,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<TaskModal
|
||||||
v-model="taskDrawerOpen"
|
v-model="taskDrawerOpen"
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
@@ -191,6 +253,10 @@ const selectedGroupId = ref<number | null>(null)
|
|||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedAssigneeId = ref<number | null>(null)
|
const selectedAssigneeId = ref<number | null>(null)
|
||||||
const selectedStatusId = 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 dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const taskDrawerOpen = ref(false)
|
const taskDrawerOpen = ref(false)
|
||||||
@@ -213,6 +279,14 @@ const statusFilterOptions = computed(() =>
|
|||||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
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(() => {
|
const filteredTasks = computed(() => {
|
||||||
let result = tasks.value.filter(t => !t.archived)
|
let result = tasks.value.filter(t => !t.archived)
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
@@ -227,6 +301,12 @@ const filteredTasks = computed(() => {
|
|||||||
if (selectedStatusId.value) {
|
if (selectedStatusId.value) {
|
||||||
result = result.filter(t => t.status?.id === 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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -311,6 +391,52 @@ async function onDropBacklog(event: DragEvent) {
|
|||||||
await taskService.update(taskId, { status: null })
|
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() {
|
async function onSaved() {
|
||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,34 +13,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
<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">
|
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||||
{{ currentMonthLabel }}
|
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigatePrev">
|
||||||
</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">
|
|
||||||
<Icon name="mdi:chevron-left" size="20" />
|
<Icon name="mdi:chevron-left" size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||||
<button
|
{{ currentMonthLabel }}
|
||||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
</h2>
|
||||||
:key="mode"
|
<button class="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-700 transition" @click="navigateNext">
|
||||||
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">
|
|
||||||
<Icon name="mdi:chevron-right" size="20" />
|
<Icon name="mdi:chevron-right" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedUserId"
|
v-model="selectedUserId"
|
||||||
@@ -76,8 +75,6 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user