2026-09-03 18:59:51 +01:00
|
|
|
<script setup lang="ts">
|
2026-09-04 20:31:10 +01:00
|
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
|
|
|
import { useRoute } from 'vue-router'
|
2026-09-03 18:59:51 +01:00
|
|
|
import draggable from 'vuedraggable'
|
2026-09-04 11:28:59 +01:00
|
|
|
import CardRow from '../components/CardRow.vue'
|
2026-09-04 13:37:17 +01:00
|
|
|
import KanbanCard from '../components/KanbanCard.vue'
|
2026-09-04 20:31:10 +01:00
|
|
|
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
2026-09-03 18:59:51 +01:00
|
|
|
import { ApiError, apiRequest } from '../lib/api'
|
2026-09-04 23:35:33 +01:00
|
|
|
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
|
2026-09-04 11:28:59 +01:00
|
|
|
import { useCardsStore } from '../stores/cards'
|
2026-09-04 15:22:33 +01:00
|
|
|
import { useInboxStore } from '../stores/inbox'
|
2026-09-04 13:37:17 +01:00
|
|
|
import type { Card, CardStatus, Project } from '../types'
|
2026-09-03 18:59:51 +01:00
|
|
|
|
|
|
|
|
const route = useRoute()
|
2026-09-04 11:28:59 +01:00
|
|
|
const cards = useCardsStore()
|
2026-09-04 15:22:33 +01:00
|
|
|
const inbox = useInboxStore()
|
2026-09-03 18:59:51 +01:00
|
|
|
|
2026-09-04 11:28:59 +01:00
|
|
|
const projectId = Number(route.params.id)
|
|
|
|
|
const project = ref<Project | null>(null)
|
2026-09-04 13:37:17 +01:00
|
|
|
const statuses = ref<CardStatus[]>([])
|
2026-09-03 18:59:51 +01:00
|
|
|
const loadError = ref<string | null>(null)
|
|
|
|
|
const actionError = ref<string | null>(null)
|
|
|
|
|
|
2026-09-04 15:31:30 +01:00
|
|
|
// Tab order is unchanged (All tasks first); Kanban is just the default view.
|
2026-09-04 13:37:17 +01:00
|
|
|
const tabs = [
|
|
|
|
|
{ key: 'all', label: 'All tasks' },
|
|
|
|
|
{ key: 'kanban', label: 'Kanban' },
|
|
|
|
|
] as const
|
2026-09-04 15:31:30 +01:00
|
|
|
const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
|
2026-09-04 13:37:17 +01:00
|
|
|
|
2026-09-03 18:59:51 +01:00
|
|
|
const newText = ref('')
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
|
|
|
|
const summary = computed(() => {
|
2026-09-04 11:28:59 +01:00
|
|
|
const total = cards.cards.length
|
|
|
|
|
if (total === 0) return 'No cards yet.'
|
2026-09-04 13:37:17 +01:00
|
|
|
return `${total} card${total === 1 ? '' : 's'}.`
|
2026-09-03 18:59:51 +01:00
|
|
|
})
|
|
|
|
|
|
2026-09-04 13:37:17 +01:00
|
|
|
// The "all tasks" list has no manual order — sort by name, case-insensitively.
|
|
|
|
|
const sortedCards = computed(() =>
|
|
|
|
|
[...cards.cards].sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' })),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// --- Kanban board --------------------------------------------------------
|
2026-09-04 15:22:33 +01:00
|
|
|
// One column per project status -- the inbox lives in the sidebar now, not
|
|
|
|
|
// here, though it's still a valid drag source/target (shared "kanban" group).
|
2026-09-04 13:37:17 +01:00
|
|
|
interface Column {
|
|
|
|
|
key: string
|
|
|
|
|
title: string
|
2026-09-04 15:22:33 +01:00
|
|
|
statusId: number
|
2026-09-04 13:37:17 +01:00
|
|
|
cards: Card[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const board = ref<Column[]>([])
|
|
|
|
|
|
|
|
|
|
function buildColumns(): Column[] {
|
2026-09-04 15:22:33 +01:00
|
|
|
return statuses.value.map((s) => ({
|
|
|
|
|
key: `status-${s.id}`,
|
|
|
|
|
title: s.name,
|
|
|
|
|
statusId: s.id,
|
|
|
|
|
cards: cards.cards.filter((card) => card.status_id === s.id),
|
2026-09-04 13:37:17 +01:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rebuildBoard() {
|
|
|
|
|
board.value = buildColumns()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-derive the columns whenever the underlying cards or the status set change
|
|
|
|
|
// (e.g. after a move is persisted, or a failed move is rolled back).
|
|
|
|
|
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
|
|
|
|
|
|
2026-09-04 15:22:33 +01:00
|
|
|
async function onColumnChange(change: ColumnChange, column: Column) {
|
|
|
|
|
// `added` (card dragged in, from another column here or from the sidebar's
|
|
|
|
|
// inbox) or `moved` (reordered within this one): persist this column's new
|
|
|
|
|
// id order. The column it left -- another status, or the inbox -- is
|
|
|
|
|
// re-packed server-side. `removed` needs no action here.
|
|
|
|
|
if (!change.added && !change.moved) return
|
|
|
|
|
|
|
|
|
|
actionError.value = null
|
|
|
|
|
try {
|
|
|
|
|
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
|
|
|
|
|
} catch (e) {
|
|
|
|
|
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
|
|
|
|
|
} finally {
|
|
|
|
|
// Either side of the drag could have been the inbox, so refresh both.
|
|
|
|
|
await Promise.all([inbox.load(), cards.load(projectId)])
|
2026-09-04 13:37:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 20:31:10 +01:00
|
|
|
onMounted(() => void load())
|
2026-09-03 18:59:51 +01:00
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
loadError.value = null
|
|
|
|
|
try {
|
2026-09-04 13:37:17 +01:00
|
|
|
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
|
2026-09-04 11:28:59 +01:00
|
|
|
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
|
2026-09-04 13:37:17 +01:00
|
|
|
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
|
2026-09-04 11:28:59 +01:00
|
|
|
cards.load(projectId),
|
2026-09-03 18:59:51 +01:00
|
|
|
])
|
2026-09-04 11:28:59 +01:00
|
|
|
project.value = fetched
|
2026-09-04 13:37:17 +01:00
|
|
|
statuses.value = fetchedStatuses
|
|
|
|
|
rebuildBoard()
|
2026-09-03 18:59:51 +01:00
|
|
|
} catch (e) {
|
|
|
|
|
if (e instanceof ApiError && e.status === 404) {
|
2026-09-04 11:28:59 +01:00
|
|
|
loadError.value = 'That project does not exist.'
|
2026-09-03 18:59:51 +01:00
|
|
|
} else {
|
2026-09-04 11:28:59 +01:00
|
|
|
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
|
2026-09-03 18:59:51 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Run a store mutation, surfacing failures and resyncing from the server. */
|
|
|
|
|
async function run(op: Promise<unknown>) {
|
|
|
|
|
actionError.value = null
|
|
|
|
|
try {
|
|
|
|
|
await op
|
|
|
|
|
} catch (e) {
|
|
|
|
|
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
|
2026-09-04 11:28:59 +01:00
|
|
|
await cards.load(projectId)
|
2026-09-03 18:59:51 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onCreate() {
|
|
|
|
|
submitting.value = true
|
|
|
|
|
actionError.value = null
|
|
|
|
|
try {
|
2026-09-04 11:28:59 +01:00
|
|
|
await cards.add(newText.value)
|
2026-09-03 18:59:51 +01:00
|
|
|
newText.value = ''
|
|
|
|
|
} catch (e) {
|
2026-09-04 11:28:59 +01:00
|
|
|
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
|
2026-09-03 18:59:51 +01:00
|
|
|
} finally {
|
|
|
|
|
submitting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-05 00:10:52 +01:00
|
|
|
|
|
|
|
|
// --- add a card directly into one kanban column -- one draft per status, so
|
|
|
|
|
// typing in one column's form doesn't touch another's.
|
|
|
|
|
const newColumnCardText = ref<Record<number, string>>({})
|
|
|
|
|
const addingToStatusId = ref<number | null>(null)
|
|
|
|
|
|
|
|
|
|
async function onCreateInColumn(column: Column) {
|
|
|
|
|
addingToStatusId.value = column.statusId
|
|
|
|
|
actionError.value = null
|
|
|
|
|
try {
|
|
|
|
|
await cards.add(newColumnCardText.value[column.statusId] ?? '', column.statusId)
|
|
|
|
|
newColumnCardText.value[column.statusId] = ''
|
|
|
|
|
} catch (e) {
|
|
|
|
|
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
|
|
|
|
|
} finally {
|
|
|
|
|
addingToStatusId.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-03 18:59:51 +01:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-04 13:37:17 +01:00
|
|
|
<section class="card project">
|
2026-09-03 18:59:51 +01:00
|
|
|
<p v-if="loadError" class="form-error">{{ loadError }}</p>
|
|
|
|
|
|
2026-09-04 11:28:59 +01:00
|
|
|
<template v-else-if="project">
|
2026-09-04 20:14:03 +01:00
|
|
|
<div class="project-head">
|
2026-09-05 00:02:09 +01:00
|
|
|
<h1 class="project-head__title">
|
|
|
|
|
<RouterLink :to="{ name: 'dashboard' }" class="title-back" aria-label="Back to dashboard">←</RouterLink>
|
|
|
|
|
{{ project.title }}
|
|
|
|
|
</h1>
|
2026-09-03 19:10:31 +01:00
|
|
|
|
2026-09-04 21:18:26 +01:00
|
|
|
<div class="project-head__actions">
|
|
|
|
|
<ProjectManageMenu
|
|
|
|
|
:project-id="projectId"
|
|
|
|
|
:project-title="project.title"
|
|
|
|
|
:card-count="cards.cards.length"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-09-03 19:10:31 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-09-03 18:59:51 +01:00
|
|
|
<p v-if="actionError" class="form-error">{{ actionError }}</p>
|
|
|
|
|
|
2026-09-04 13:37:17 +01:00
|
|
|
<div class="tabs" role="tablist">
|
|
|
|
|
<button
|
|
|
|
|
v-for="tab in tabs"
|
|
|
|
|
:key="tab.key"
|
|
|
|
|
type="button"
|
|
|
|
|
role="tab"
|
|
|
|
|
:aria-selected="activeTab === tab.key"
|
|
|
|
|
class="tabs__tab"
|
|
|
|
|
:class="{ 'tabs__tab--active': activeTab === tab.key }"
|
|
|
|
|
@click="activeTab = tab.key"
|
|
|
|
|
>
|
|
|
|
|
{{ tab.label }}
|
2026-09-03 18:59:51 +01:00
|
|
|
</button>
|
2026-09-04 13:37:17 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tab: All tasks -->
|
2026-09-04 21:09:31 +01:00
|
|
|
<div v-show="activeTab === 'all'" class="tabs__panel" role="tabpanel">
|
2026-09-04 13:37:17 +01:00
|
|
|
<p class="muted">{{ summary }}</p>
|
|
|
|
|
|
|
|
|
|
<p v-if="cards.loading && !cards.loaded" class="muted">Loading…</p>
|
|
|
|
|
|
|
|
|
|
<ul v-else-if="sortedCards.length" class="cards">
|
|
|
|
|
<CardRow
|
|
|
|
|
v-for="card in sortedCards"
|
|
|
|
|
:key="card.id"
|
|
|
|
|
:card="card"
|
|
|
|
|
@delete="run(cards.remove(card))"
|
|
|
|
|
/>
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
<form class="form form--new-card" @submit.prevent="onCreate">
|
|
|
|
|
<label>
|
|
|
|
|
<span>New card</span>
|
2026-09-04 22:03:38 +01:00
|
|
|
<input v-model="newText" class="field" type="text" maxlength="1000" required />
|
2026-09-04 13:37:17 +01:00
|
|
|
</label>
|
|
|
|
|
<button type="submit" :disabled="submitting">
|
|
|
|
|
{{ submitting ? 'Adding…' : 'Add card' }}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tab: Kanban -->
|
|
|
|
|
<div v-show="activeTab === 'kanban'" class="tabs__panel" role="tabpanel">
|
|
|
|
|
<p v-if="cards.loading && !cards.loaded" class="muted">Loading…</p>
|
|
|
|
|
|
|
|
|
|
<div v-else class="kanban">
|
2026-09-04 15:22:33 +01:00
|
|
|
<section v-for="column in board" :key="column.key" class="kanban__col">
|
2026-09-04 13:37:17 +01:00
|
|
|
<header class="kanban__head">
|
|
|
|
|
<span class="kanban__title">{{ column.title }}</span>
|
|
|
|
|
<span class="kanban__count">{{ column.cards.length }}</span>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<draggable
|
|
|
|
|
:list="column.cards"
|
|
|
|
|
:group="{ name: 'kanban' }"
|
|
|
|
|
item-key="id"
|
|
|
|
|
class="kanban__cards"
|
|
|
|
|
ghost-class="kanban-card--ghost"
|
|
|
|
|
:animation="150"
|
|
|
|
|
@change="(e: ColumnChange) => onColumnChange(e, column)"
|
|
|
|
|
>
|
|
|
|
|
<template #item="{ element }: { element: Card }">
|
|
|
|
|
<KanbanCard :card="element" />
|
|
|
|
|
</template>
|
|
|
|
|
</draggable>
|
2026-09-05 00:10:52 +01:00
|
|
|
|
|
|
|
|
<form class="kanban__new" @submit.prevent="onCreateInColumn(column)">
|
|
|
|
|
<input
|
|
|
|
|
v-model="newColumnCardText[column.statusId]"
|
|
|
|
|
class="field field--compact"
|
|
|
|
|
type="text"
|
|
|
|
|
maxlength="1000"
|
|
|
|
|
required
|
|
|
|
|
placeholder="New card"
|
|
|
|
|
:aria-label="`New card in ${column.title}`"
|
|
|
|
|
/>
|
|
|
|
|
<button type="submit" :disabled="addingToStatusId === column.statusId">
|
|
|
|
|
{{ addingToStatusId === column.statusId ? 'Adding…' : 'Add' }}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
2026-09-04 13:37:17 +01:00
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-09-03 18:59:51 +01:00
|
|
|
</template>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|