Files
KanbanGit/resources/js/pages/projects/Board.vue
T
Twyco a46695c2eb
linter / quality (pull_request) Successful in 1m18s
tests / ci (8.4) (pull_request) Successful in 1m54s
Implement Fluid Emerald app redesign
2026-07-18 16:45:54 +02:00

382 lines
18 KiB
Vue

<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { ArrowRight, CircleUserRound } from 'lucide-vue-next';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board',
href: boardIndex(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board`" />
<div class="app-page max-w-none">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} board`"
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
<Card v-if="boardColumns.length === 0">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
The board has no workflow columns yet, so no tickets can
appear here.
</p>
<div v-if="canEditTickets">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Open board settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<div
v-else
class="-mx-4 flex snap-x snap-proximity items-start gap-4 overflow-x-auto px-4 pb-5 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
>
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="bg-surface-container-low w-[min(21rem,calc(100vw-3rem))] shrink-0 snap-start gap-0 overflow-hidden border-0 py-0 shadow-none hover:shadow-none sm:w-[21rem]"
>
<CardHeader
class="border-outline-variant/60 sticky top-0 z-10 border-b px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
{{ boardColumn.tickets?.length ?? 0 }}
</Badge>
</div>
</CardHeader>
<CardContent
class="flex min-h-48 flex-1 flex-col gap-3 p-3"
>
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="empty-state bg-transparent"
>
No tickets are currently in this column.
</div>
<div v-else class="flex flex-col gap-3">
<Card
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'shadow-xs gap-0 py-0 hover:-translate-y-0.5',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
>
<CardHeader class="gap-3 px-4 pb-3 pt-4">
<div class="flex flex-wrap gap-2">
<Badge
variant="outline"
class="text-primary"
>
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
v-if="ticket.description"
class="line-clamp-3"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
<div
class="border-outline-variant/60 flex items-center justify-between gap-3 border-t pt-3"
>
<div
class="flex min-w-0 items-center gap-2"
>
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
class="size-7"
/>
<span
v-else
class="border-outline-variant text-muted-foreground flex size-7 items-center justify-center rounded-full border border-dashed"
>
<CircleUserRound
class="size-4"
/>
</span>
<span
class="text-muted-foreground truncate text-xs"
>
{{
ticket.assignee?.name ??
'Unassigned'
}}
</span>
</div>
<div
class="flex shrink-0 items-center gap-1.5"
>
<span
class="bg-primary size-1.5 rounded-full"
/>
<span
class="text-muted-foreground text-xs font-medium"
>
{{
ticket.ticket_priority.name
}}
</span>
</div>
</div>
</CardHeader>
<CardContent
v-if="canEditTickets"
class="border-outline-variant/60 border-t px-4 py-3"
>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<div class="flex items-center gap-2">
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="min-w-0 flex-1 text-xs"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="icon-sm"
variant="ghost"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
<ArrowRight class="size-4" />
<span class="sr-only"
>Update status</span
>
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>