feat: add mobile-first kanban board view for Chorus triage items
This commit is contained in:
parent
da0e703b44
commit
634da8c309
2 changed files with 356 additions and 0 deletions
269
frontend/src/components/KanbanBoard.vue
Normal file
269
frontend/src/components/KanbanBoard.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import ModalityBadge from './ModalityBadge.vue'
|
||||
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
|
||||
|
||||
const props = defineProps({ items: { type: Array, required: true } })
|
||||
const emit = defineEmits(['select-item'])
|
||||
|
||||
const BOARDS = [
|
||||
{ key: 'donation', label: 'Donations', stages: DONATION_STAGES },
|
||||
{ key: 'other', label: 'Other', stages: OTHER_STAGES },
|
||||
{ key: 'outbound', label: 'Outbound Leads', stages: OUTBOUND_STAGES },
|
||||
{ key: 'unsorted', label: 'Unsorted', stages: UNSORTED_STAGES },
|
||||
]
|
||||
|
||||
function bucketFor(item) {
|
||||
if (item.direction === 'outbound') return 'outbound'
|
||||
if (item.type === 'donation') return 'donation'
|
||||
if (item.type === 'other') return 'other'
|
||||
return 'unsorted'
|
||||
}
|
||||
|
||||
const activeBoardKey = ref(BOARDS[0].key)
|
||||
const activeStage = ref(BOARDS[0].stages[0])
|
||||
|
||||
const activeBoard = computed(() => BOARDS.find((b) => b.key === activeBoardKey.value))
|
||||
|
||||
watch(activeBoardKey, (key) => {
|
||||
activeStage.value = BOARDS.find((b) => b.key === key).stages[0]
|
||||
})
|
||||
|
||||
const bucketedItems = computed(() => {
|
||||
const grouped = {}
|
||||
for (const board of BOARDS) grouped[board.key] = []
|
||||
for (const item of props.items) {
|
||||
grouped[bucketFor(item)].push(item)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
function itemsForStage(boardKey, stage) {
|
||||
return bucketedItems.value[boardKey].filter((i) => i.stage === stage)
|
||||
}
|
||||
|
||||
function boardCount(boardKey) {
|
||||
return bucketedItems.value[boardKey].length
|
||||
}
|
||||
|
||||
function selectBoard(key) {
|
||||
activeBoardKey.value = key
|
||||
}
|
||||
|
||||
function selectStage(stage) {
|
||||
activeStage.value = stage
|
||||
}
|
||||
|
||||
function labelFor(stage) {
|
||||
return STAGE_LABELS[stage] || stage
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-board">
|
||||
<div class="board-tabs" role="tablist" aria-label="Item category">
|
||||
<button
|
||||
v-for="board in BOARDS"
|
||||
:key="board.key"
|
||||
role="tab"
|
||||
:id="`board-tab-${board.key}`"
|
||||
:aria-selected="board.key === activeBoardKey"
|
||||
:aria-controls="`board-panel-${board.key}`"
|
||||
:tabindex="board.key === activeBoardKey ? 0 : -1"
|
||||
type="button"
|
||||
class="board-tab"
|
||||
:class="{ active: board.key === activeBoardKey }"
|
||||
@click="selectBoard(board.key)"
|
||||
>
|
||||
{{ board.label }} ({{ boardCount(board.key) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-breadcrumb" role="tabpanel" :id="`board-panel-${activeBoardKey}`">
|
||||
{{ activeBoard.label }} › {{ labelFor(activeStage) }}
|
||||
</div>
|
||||
|
||||
<div class="mobile-stage-strip">
|
||||
<button
|
||||
v-for="stage in activeBoard.stages"
|
||||
:key="stage"
|
||||
type="button"
|
||||
:aria-pressed="stage === activeStage"
|
||||
class="stage-strip-button"
|
||||
:class="{ active: stage === activeStage }"
|
||||
@click="selectStage(stage)"
|
||||
>
|
||||
{{ labelFor(stage) }} ({{ itemsForStage(activeBoardKey, stage).length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div
|
||||
v-for="stage in activeBoard.stages"
|
||||
:key="stage"
|
||||
class="column"
|
||||
:class="{ 'is-active-mobile': stage === activeStage }"
|
||||
>
|
||||
<h3>{{ labelFor(stage) }} ({{ itemsForStage(activeBoardKey, stage).length }})</h3>
|
||||
<button
|
||||
v-for="item in itemsForStage(activeBoardKey, stage)"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="card"
|
||||
@click="emit('select-item', item.id)"
|
||||
>
|
||||
<ModalityBadge :modality="item.modality" />
|
||||
<p class="card-sender">{{ item.sender_id || 'not set' }}</p>
|
||||
<p class="card-snippet">{{ item.raw_content.slice(0, 80) }}</p>
|
||||
<p v-if="item.follow_up_date" class="card-followup">Follow up: {{ item.follow_up_date }}</p>
|
||||
</button>
|
||||
<p v-if="itemsForStage(activeBoardKey, stage).length === 0" class="column-empty">Nothing here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.board-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.board-tab {
|
||||
min-height: 44px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-tab.active {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.mobile-breadcrumb {
|
||||
display: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-stage-strip {
|
||||
display: none;
|
||||
gap: var(--spacing-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage-strip-button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-strip-button.active {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.column h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 44px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
padding: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.card-sender {
|
||||
font-weight: 600;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-followup {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.column-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mobile-breadcrumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-stage-strip {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.columns {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column.is-active-mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
frontend/tests/KanbanBoard.spec.js
Normal file
87
frontend/tests/KanbanBoard.spec.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import KanbanBoard from '../src/components/KanbanBoard.vue'
|
||||
|
||||
function makeItem(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
modality: 'bh_email',
|
||||
sender_id: 'jane@example.com',
|
||||
raw_content: 'I have some books to donate.',
|
||||
type: null,
|
||||
stage: 'new',
|
||||
direction: 'inbound',
|
||||
follow_up_date: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('KanbanBoard', () => {
|
||||
it('buckets items into the correct board and shows counts on tabs', () => {
|
||||
const items = [
|
||||
makeItem({ id: 1, type: 'donation', direction: 'inbound', stage: 'new' }),
|
||||
makeItem({ id: 2, type: 'other', direction: 'inbound', stage: 'new' }),
|
||||
makeItem({ id: 3, type: null, direction: 'outbound', stage: 'new' }),
|
||||
makeItem({ id: 4, type: null, direction: 'inbound', stage: 'new' }),
|
||||
]
|
||||
const wrapper = mount(KanbanBoard, { props: { items } })
|
||||
const tabs = wrapper.findAll('[role="tab"]').map((t) => t.text())
|
||||
expect(tabs).toEqual([
|
||||
'Donations (1)', 'Other (1)', 'Outbound Leads (1)', 'Unsorted (1)',
|
||||
])
|
||||
})
|
||||
|
||||
it('defaults to the Donations board and its first stage', () => {
|
||||
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
||||
const activeTab = wrapper.find('[role="tab"][aria-selected="true"]')
|
||||
expect(activeTab.text()).toContain('Donations')
|
||||
})
|
||||
|
||||
it('shows humanized stage labels, not raw stage strings, on column headers', () => {
|
||||
const items = [makeItem({ id: 1, type: 'donation', stage: 'pickup_scheduled' })]
|
||||
const wrapper = mount(KanbanBoard, { props: { items } })
|
||||
expect(wrapper.text()).toContain('Pickup Scheduled')
|
||||
expect(wrapper.text()).not.toContain('pickup_scheduled')
|
||||
})
|
||||
|
||||
it('switching board tabs resets the active stage to that board\'s first stage', async () => {
|
||||
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
||||
const outboundTab = wrapper.findAll('[role="tab"]').find((t) => t.text().includes('Outbound Leads'))
|
||||
await outboundTab.trigger('click')
|
||||
const breadcrumb = wrapper.find('.mobile-breadcrumb')
|
||||
expect(breadcrumb.text()).toContain('New')
|
||||
expect(breadcrumb.text()).toContain('Outbound Leads')
|
||||
})
|
||||
|
||||
it('clicking a stage-strip button switches the active stage', async () => {
|
||||
const items = [makeItem({ id: 1, type: 'donation', stage: 'replied' })]
|
||||
const wrapper = mount(KanbanBoard, { props: { items } })
|
||||
const repliedButton = wrapper.findAll('.stage-strip-button').find((b) => b.text().includes('Replied'))
|
||||
await repliedButton.trigger('click')
|
||||
expect(wrapper.find('.mobile-breadcrumb').text()).toContain('Replied')
|
||||
})
|
||||
|
||||
it('clicking a card emits select-item with the item id', async () => {
|
||||
const items = [makeItem({ id: 42, type: 'donation', stage: 'new' })]
|
||||
const wrapper = mount(KanbanBoard, { props: { items } })
|
||||
await wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted('select-item')).toEqual([[42]])
|
||||
})
|
||||
|
||||
it('renders an empty-state message for a column with no items', () => {
|
||||
const wrapper = mount(KanbanBoard, { props: { items: [] } })
|
||||
expect(wrapper.text()).toContain('Nothing here')
|
||||
})
|
||||
|
||||
it('applies the is-active-mobile class only to the currently active stage column', () => {
|
||||
const items = [
|
||||
makeItem({ id: 1, type: 'donation', stage: 'new' }),
|
||||
makeItem({ id: 2, type: 'donation', stage: 'replied' }),
|
||||
]
|
||||
const wrapper = mount(KanbanBoard, { props: { items } })
|
||||
const columns = wrapper.findAll('.column')
|
||||
const activeColumns = columns.filter((c) => c.classes().includes('is-active-mobile'))
|
||||
expect(activeColumns).toHaveLength(1)
|
||||
expect(activeColumns[0].text()).toContain('New')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue