import { Component, ElementRef, EventEmitter, Input, Output, QueryList, ViewChildren, } from '@angular/core'; import { VspBlock } from './blocks.component'; import { VspBadge, type BadgeTone } from './badge.component'; import { VspAvatar } from './media.component'; export interface KanbanCard { id: string; title: string; tag: string; tone: BadgeTone; } export interface KanbanColumn { name: string; /** CSS colour for the column's status dot. */ tone: string; cards: KanbanCard[]; } interface DragState { card: KanbanCard; from: number; origIndex: number; w: number; offX: number; offY: number; } interface DropTarget { col: number; index: number; } const DEFAULT_COLUMNS: KanbanColumn[] = [ { name: 'Triage', tone: 'var(--text-faint)', cards: [ { id: 'k1', title: 'Cobalt payment failed', tag: 'Billing', tone: 'neg' }, { id: 'k2', title: 'Verify SSO config', tag: 'Security', tone: 'warn' }, ], }, { name: 'In progress', tone: 'var(--accent)', cards: [ { id: 'k3', title: 'Migrate Halcyon seats', tag: 'Accounts', tone: 'info' }, { id: 'k4', title: 'Q2 expansion review', tag: 'Revenue', tone: 'info' }, { id: 'k5', title: 'Webhook retry logic', tag: 'Product', tone: 'warn' }, ], }, { name: 'Done', tone: 'var(--success)', cards: [ { id: 'k6', title: 'Ship usage billing', tag: 'Product', tone: 'pos' }, { id: 'k7', title: 'Reconcile invoices', tag: 'Finance', tone: 'pos' }, ], }, ]; /** A lightweight kanban — drag cards to reorder or move between columns. */ @Component({ selector: 'vsp-kanban-block', imports: [VspBlock, VspBadge, VspAvatar], template: `
@for (col of cols; track col.name; let ci = $index) {
{{ col.name }} {{ col.cards.length }}
@for (card of items(col); track card.id; let i = $index) { @if (showPh(ci) && target!.index === i) {
}
{{ card.title }}
{{ card.tag }}
} @if (showPh(ci) && target!.index >= items(col).length) {
} @if (items(col).length === 0 && !showPh(ci)) {
Drop here
}
}
@if (drag) {
{{ drag.card.title }}
{{ drag.card.tag }}
}
`, }) export class VspKanbanBlock { @Input() columns: KanbanColumn[] = DEFAULT_COLUMNS; @Output() change = new EventEmitter(); @ViewChildren('colEl') colEls!: QueryList>; cols: KanbanColumn[] = DEFAULT_COLUMNS.map((c) => ({ ...c, cards: [...c.cards] })); drag: DragState | null = null; pt = { x: 0, y: 0 }; target: DropTarget | null = null; private dragData: DragState | null = null; private targetData: DropTarget | null = null; ngOnInit(): void { this.cols = this.columns.map((c) => ({ ...c, cards: [...c.cards] })); } items(col: KanbanColumn): KanbanCard[] { return col.cards.filter((c) => !(this.drag && c.id === this.drag.card.id)); } showPh(ci: number): boolean { return !!this.drag && !!this.target && this.target.col === ci; } startDrag(e: PointerEvent, card: KanbanCard, ci: number): void { if (e.button !== 0) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const origIndex = this.cols[ci].cards.findIndex((c) => c.id === card.id); const d: DragState = { card, from: ci, origIndex, w: rect.width, offX: e.clientX - rect.left, offY: e.clientY - rect.top, }; const home = { col: ci, index: origIndex }; this.dragData = d; this.targetData = home; this.drag = d; this.pt = { x: e.clientX, y: e.clientY }; this.target = home; e.preventDefault(); document.body.style.userSelect = 'none'; document.body.style.cursor = 'grabbing'; window.addEventListener('pointermove', this.onMove); window.addEventListener('pointerup', this.onUp); } private onMove = (e: PointerEvent): void => { const d = this.dragData; if (!d) return; this.pt = { x: e.clientX, y: e.clientY }; let found: DropTarget | null = null; this.colEls.forEach((ref, ci) => { const el = ref.nativeElement; const r = el.getBoundingClientRect(); if ( e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top - 60 && e.clientY <= r.bottom + 80 ) { const cards = Array.from(el.querySelectorAll('[data-kcard]')); let idx = cards.length; for (let i = 0; i < cards.length; i++) { const cr = cards[i].getBoundingClientRect(); if (e.clientY < cr.top + cr.height / 2) { idx = i; break; } } found = { col: ci, index: idx }; } }); if (!found) found = { col: d.from, index: d.origIndex }; this.targetData = found; this.target = found; }; private onUp = (): void => { const dd = this.dragData; const tg = this.targetData; if (dd && tg) { const next = this.cols.map((c) => ({ ...c, cards: c.cards.filter((x) => x.id !== dd.card.id), })); next[tg.col].cards.splice(tg.index, 0, dd.card); this.cols = next; this.change.emit(next); } this.dragData = null; this.targetData = null; this.drag = null; this.target = null; document.body.style.userSelect = ''; document.body.style.cursor = ''; window.removeEventListener('pointermove', this.onMove); window.removeEventListener('pointerup', this.onUp); }; }