445 lines
17 KiB
Vue
445 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link } from '@inertiajs/vue3';
|
|
import {
|
|
ArrowLeft,
|
|
ChevronsDownUp,
|
|
ChevronsUpDown,
|
|
Columns3,
|
|
Plus,
|
|
RotateCcw,
|
|
Search,
|
|
} from 'lucide-vue-next';
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
|
|
import FeedbackState from '@/components/FeedbackState.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import {
|
|
buildBacklogTicketTree,
|
|
countBacklogTreeTickets,
|
|
expandableBacklogTicketIds,
|
|
filterBacklogTicketTree,
|
|
} from '@/lib/backlog';
|
|
import { index as boardIndex } from '@/routes/projects/board';
|
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
|
import { create as createTicket } from '@/routes/projects/tickets';
|
|
import type {
|
|
BacklogFilterState,
|
|
BreadcrumbItem,
|
|
ProjectBacklogPageProps,
|
|
} from '@/types';
|
|
|
|
const props = defineProps<ProjectBacklogPageProps>();
|
|
const { t } = useLocalization();
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('navigation.projects'), href: projectIndex() },
|
|
{ title: props.project.name, href: show(props.project.id) },
|
|
{ title: t('navigation.backlog'), href: backlogIndex(props.project.id) },
|
|
]);
|
|
|
|
const filters = reactive<BacklogFilterState>({
|
|
assigneeId: '',
|
|
priorityId: '',
|
|
query: '',
|
|
statusId: '',
|
|
typeId: '',
|
|
});
|
|
const collapsedTicketIds = reactive(new Set<number>());
|
|
const pendingTicketId = ref<number | null>(null);
|
|
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
|
|
const filteredTicketTree = computed(() =>
|
|
filterBacklogTicketTree(ticketTree.value, filters),
|
|
);
|
|
const visibleTicketCount = computed(() =>
|
|
countBacklogTreeTickets(filteredTicketTree.value),
|
|
);
|
|
const expandableTicketIds = computed(() =>
|
|
expandableBacklogTicketIds(filteredTicketTree.value),
|
|
);
|
|
const hasActiveFilters = computed(
|
|
() =>
|
|
filters.query.trim() !== '' ||
|
|
filters.typeId !== '' ||
|
|
filters.priorityId !== '' ||
|
|
filters.statusId !== '' ||
|
|
filters.assigneeId !== '',
|
|
);
|
|
const allVisibleBranchesCollapsed = computed(
|
|
() =>
|
|
expandableTicketIds.value.length > 0 &&
|
|
expandableTicketIds.value.every((ticketId) =>
|
|
collapsedTicketIds.has(ticketId),
|
|
),
|
|
);
|
|
|
|
function clearFilters(): void {
|
|
filters.query = '';
|
|
filters.typeId = '';
|
|
filters.priorityId = '';
|
|
filters.statusId = '';
|
|
filters.assigneeId = '';
|
|
}
|
|
|
|
function toggleTicket(ticketId: number): void {
|
|
if (collapsedTicketIds.has(ticketId)) {
|
|
collapsedTicketIds.delete(ticketId);
|
|
} else {
|
|
collapsedTicketIds.add(ticketId);
|
|
}
|
|
}
|
|
|
|
function toggleAllBranches(): void {
|
|
if (allVisibleBranchesCollapsed.value) {
|
|
collapsedTicketIds.clear();
|
|
|
|
return;
|
|
}
|
|
|
|
for (const ticketId of expandableTicketIds.value) {
|
|
collapsedTicketIds.add(ticketId);
|
|
}
|
|
}
|
|
|
|
function finishMove(ticketId: number): void {
|
|
if (pendingTicketId.value === ticketId) {
|
|
pendingTicketId.value = null;
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.project.id,
|
|
() => {
|
|
clearFilters();
|
|
collapsedTicketIds.clear();
|
|
pendingTicketId.value = null;
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('backlog.title', { name: project.name })" />
|
|
|
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
|
<header
|
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
|
>
|
|
<div class="grid min-w-0 gap-2">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
|
<Badge
|
|
variant="outline"
|
|
class="wrap-anywhere max-w-full whitespace-normal"
|
|
>
|
|
{{ project.key }}
|
|
</Badge>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('tickets.count', { count: tickets.length }) }}
|
|
</p>
|
|
</div>
|
|
<h1
|
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
|
>
|
|
{{ t('backlog.title', { name: project.name }) }}
|
|
</h1>
|
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
|
{{ t('backlog.description') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button v-if="canCreateTickets" as-child>
|
|
<Link
|
|
:href="
|
|
createTicket(
|
|
{ project: project.id },
|
|
{ query: { source: 'backlog' } },
|
|
)
|
|
"
|
|
>
|
|
<Plus aria-hidden="true" />
|
|
{{ t('navigation.createTicket') }}
|
|
</Link>
|
|
</Button>
|
|
<Button as-child variant="outline">
|
|
<Link :href="boardIndex(project.id)">
|
|
<Columns3 aria-hidden="true" />
|
|
{{ t('backlog.openBoard') }}
|
|
</Link>
|
|
</Button>
|
|
<Button as-child variant="ghost">
|
|
<Link :href="show(project.id)">
|
|
<ArrowLeft aria-hidden="true" />
|
|
{{ t('backlog.backToProject') }}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<FeedbackState v-if="status" state="success" :title="status" />
|
|
|
|
<section
|
|
aria-labelledby="backlog-filter-title"
|
|
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
|
|
>
|
|
<div
|
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="grid min-w-0 gap-1">
|
|
<h2 id="backlog-filter-title" class="text-title">
|
|
{{ t('backlog.filters.title') }}
|
|
</h2>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('backlog.filters.description') }}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!hasActiveFilters"
|
|
@click="clearFilters"
|
|
>
|
|
<RotateCcw aria-hidden="true" />
|
|
{{ t('backlog.filters.clear') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
|
|
>
|
|
<div
|
|
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
|
|
>
|
|
<label
|
|
for="backlog-query"
|
|
class="text-caption font-semibold"
|
|
>
|
|
{{ t('backlog.filters.search') }}
|
|
</label>
|
|
<div class="relative min-w-0">
|
|
<Search
|
|
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
|
aria-hidden="true"
|
|
/>
|
|
<input
|
|
id="backlog-query"
|
|
v-model="filters.query"
|
|
type="search"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
|
|
:placeholder="
|
|
t('backlog.filters.searchPlaceholder')
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-1.5">
|
|
<label
|
|
for="backlog-type"
|
|
class="text-caption font-semibold"
|
|
>
|
|
{{ t('backlog.filters.type') }}
|
|
</label>
|
|
<select
|
|
id="backlog-type"
|
|
v-model="filters.typeId"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
|
>
|
|
<option value="">
|
|
{{ t('backlog.filters.allTypes') }}
|
|
</option>
|
|
<option
|
|
v-for="ticketType in ticketTypes"
|
|
:key="ticketType.id"
|
|
:value="ticketType.id"
|
|
>
|
|
{{ ticketType.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-1.5">
|
|
<label
|
|
for="backlog-priority"
|
|
class="text-caption font-semibold"
|
|
>
|
|
{{ t('backlog.filters.priority') }}
|
|
</label>
|
|
<select
|
|
id="backlog-priority"
|
|
v-model="filters.priorityId"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
|
>
|
|
<option value="">
|
|
{{ t('backlog.filters.allPriorities') }}
|
|
</option>
|
|
<option
|
|
v-for="priority in ticketPriorities"
|
|
:key="priority.id"
|
|
:value="priority.id"
|
|
>
|
|
{{ priority.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-1.5">
|
|
<label
|
|
for="backlog-status"
|
|
class="text-caption font-semibold"
|
|
>
|
|
{{ t('backlog.filters.status') }}
|
|
</label>
|
|
<select
|
|
id="backlog-status"
|
|
v-model="filters.statusId"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
|
>
|
|
<option value="">
|
|
{{ t('backlog.filters.allStatuses') }}
|
|
</option>
|
|
<option
|
|
v-for="ticketStatus in backlogTicketStatuses"
|
|
:key="ticketStatus.id"
|
|
:value="ticketStatus.id"
|
|
>
|
|
{{ ticketStatus.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-1.5">
|
|
<label
|
|
for="backlog-assignee"
|
|
class="text-caption font-semibold"
|
|
>
|
|
{{ t('backlog.filters.assignee') }}
|
|
</label>
|
|
<select
|
|
id="backlog-assignee"
|
|
v-model="filters.assigneeId"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
|
>
|
|
<option value="">
|
|
{{ t('backlog.filters.allAssignees') }}
|
|
</option>
|
|
<option value="unassigned">
|
|
{{ t('tickets.unassigned') }}
|
|
</option>
|
|
<option
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
:value="user.id"
|
|
>
|
|
{{ user.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
v-if="tickets.length === 0"
|
|
class="grid justify-items-start gap-3"
|
|
>
|
|
<FeedbackState
|
|
state="empty"
|
|
:title="t('backlog.empty.title')"
|
|
:description="t('backlog.empty.description')"
|
|
/>
|
|
<Button v-if="canCreateTickets" as-child size="sm">
|
|
<Link
|
|
:href="
|
|
createTicket(
|
|
{ project: project.id },
|
|
{ query: { source: 'backlog' } },
|
|
)
|
|
"
|
|
>
|
|
<Plus aria-hidden="true" />
|
|
{{ t('backlog.empty.action') }}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="visibleTicketCount === 0"
|
|
class="grid justify-items-start gap-3"
|
|
>
|
|
<FeedbackState
|
|
state="empty"
|
|
:title="t('backlog.filters.empty.title')"
|
|
:description="t('backlog.filters.empty.description')"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="clearFilters"
|
|
>
|
|
<RotateCcw aria-hidden="true" />
|
|
{{ t('backlog.filters.clear') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<section
|
|
v-else
|
|
aria-labelledby="backlog-results-title"
|
|
class="grid min-w-0 gap-3"
|
|
>
|
|
<div
|
|
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div class="grid min-w-0 gap-1">
|
|
<h2 id="backlog-results-title" class="text-title">
|
|
{{ t('backlog.results.title') }}
|
|
</h2>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{
|
|
t('backlog.results.count', {
|
|
count: visibleTicketCount,
|
|
total: tickets.length,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
v-if="expandableTicketIds.length > 0"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="toggleAllBranches"
|
|
>
|
|
<ChevronsUpDown
|
|
v-if="allVisibleBranchesCollapsed"
|
|
aria-hidden="true"
|
|
/>
|
|
<ChevronsDownUp v-else aria-hidden="true" />
|
|
{{
|
|
t(
|
|
allVisibleBranchesCollapsed
|
|
? 'backlog.tree.expandAll'
|
|
: 'backlog.tree.collapseAll',
|
|
)
|
|
}}
|
|
</Button>
|
|
</div>
|
|
|
|
<BacklogTicketTree
|
|
:nodes="filteredTicketTree"
|
|
:project-id="project.id"
|
|
:can-edit-tickets="canEditTickets"
|
|
:collapsed-ticket-ids="collapsedTicketIds"
|
|
:pending-ticket-id="pendingTicketId"
|
|
@toggle="toggleTicket"
|
|
@move-start="pendingTicketId = $event"
|
|
@move-finish="finishMove"
|
|
/>
|
|
</section>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|