feat: add mobile-first kanban board view for Chorus triage items

This commit is contained in:
pyr0ball 2026-07-14 13:13:06 -07:00
parent da0e703b44
commit 634da8c309
2 changed files with 356 additions and 0 deletions

View 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 }} &rsaquo; {{ 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>

View 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')
})
})