diff --git a/frontend/src/components/board/BoardCard.vue b/frontend/src/components/board/BoardCard.vue index ab5ccf8..7b83a53 100644 --- a/frontend/src/components/board/BoardCard.vue +++ b/frontend/src/components/board/BoardCard.vue @@ -235,9 +235,9 @@ function toggleExpand(e: MouseEvent) { .mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; } .ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; } -.ball.is-iris { background: rgba(147,51,234,.16); color: #c084fc; } -.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; } -.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; } +.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); } +.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); } +.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); } .ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; } .prio-high { color: var(--st-block); border-color: var(--st-block); } @@ -263,9 +263,9 @@ function toggleExpand(e: MouseEvent) { .children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; } .child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; } -.child-agent.is-iris { background: rgba(147,51,234,.14); color: #c084fc; } -.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; } -.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; } +.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); } +.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); } +.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); } .child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); } .child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; } .child-row:hover { background: rgba(124,108,255,.08); } @@ -275,7 +275,7 @@ function toggleExpand(e: MouseEvent) { .child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; } .cs-done { background: rgba(61,220,151,.14); color: var(--st-work); } .cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); } -.cs-review { background: rgba(251,146,60,.14); color: #fdba74; } +.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); } .cs-active { background: rgba(52,214,245,.14); color: var(--st-think); } .cs-backlog { background: var(--glass-2); color: var(--tx-3); } @@ -283,7 +283,7 @@ function toggleExpand(e: MouseEvent) { .rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; } .rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); } .rv-approve:hover { background: rgba(61,220,151,.26); } -.rv-changes { border: 1px solid rgba(251,146,60,.3); background: rgba(251,146,60,.12); color: #fdba74; } +.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); } .rv-changes:hover { background: rgba(251,146,60,.22); } .mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; } diff --git a/frontend/src/components/ui/SkeletonLoader.vue b/frontend/src/components/ui/SkeletonLoader.vue new file mode 100644 index 0000000..d141400 --- /dev/null +++ b/frontend/src/components/ui/SkeletonLoader.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/ui/StatusPill.vue b/frontend/src/components/ui/StatusPill.vue new file mode 100644 index 0000000..1fa0102 --- /dev/null +++ b/frontend/src/components/ui/StatusPill.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c1a787d..e5419c8 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -5,6 +5,8 @@ export { default as PageHeading } from './PageHeading.vue' export { default as EmptyState } from './EmptyState.vue' export { default as ActivityTimeline } from './ActivityTimeline.vue' export { default as SectionHeader } from './SectionHeader.vue' +export { default as StatusPill } from './StatusPill.vue' +export { default as SkeletonLoader } from './SkeletonLoader.vue' export { default as Input } from './Input.vue' export { default as Textarea } from './Textarea.vue' export { default as Select } from './Select.vue' diff --git a/frontend/src/composables/useConfirm.ts b/frontend/src/composables/useConfirm.ts new file mode 100644 index 0000000..325a3bf --- /dev/null +++ b/frontend/src/composables/useConfirm.ts @@ -0,0 +1,75 @@ +/** + * useConfirm — Wiederverwendbare Confirmation-Dialog-Logik + * + * Extrahiert aus dem Modal-Muster in TaskBoardView + * (showCreateModal / showChangesModal / showDetailPanel), + * das sich auch in anderen Views wiederholt. + * + * Bietet reaktiven open/close-State, Form-Fehler-Management + * und Escape-Key-Bindung. + */ +import { ref, watch, onMounted, onUnmounted } from 'vue' + +export function useConfirm() { + const isOpen = ref(false) + const error = ref('') + const success = ref('') + const submitting = ref(false) + + function open() { + isOpen.value = true + error.value = '' + success.value = '' + } + + function close() { + isOpen.value = false + } + + function setError(msg: string) { + error.value = msg + success.value = '' + } + + function setSuccess(msg: string) { + success.value = msg + error.value = '' + } + + /** Escape-Taste schliesst den Dialog */ + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape' && isOpen.value) { + close() + } + } + + /** Overlay-Klick ausserhalb schliesst */ + function onOverlayClick(e: MouseEvent) { + if ((e.target as HTMLElement).classList.contains('modal-overlay')) { + close() + } + } + + /** Sperrt Body-Scroll, wenn Dialog offen */ + watch(isOpen, (open) => { + document.body.style.overflow = open ? 'hidden' : '' + }) + + onMounted(() => window.addEventListener('keydown', onKeydown)) + onUnmounted(() => { + window.removeEventListener('keydown', onKeydown) + document.body.style.overflow = '' + }) + + return { + isOpen, + error, + success, + submitting, + open, + close, + setError, + setSuccess, + onOverlayClick, + } +} diff --git a/frontend/src/composables/useFormatDate.ts b/frontend/src/composables/useFormatDate.ts new file mode 100644 index 0000000..3c3710a --- /dev/null +++ b/frontend/src/composables/useFormatDate.ts @@ -0,0 +1,60 @@ +/** + * useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2 + * + * Kapselt die mehrfach wiederholten formatDate-, relativeTime- und + * toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard, + * mit anderen Views dupliziert waren. + * + * Alle Ausgaben orientieren sich an de-DE Locale. + */ + +/** + * Datum als lesbaren String (de-DE, kurzes/medium-DateStyle). + */ +export function formatDate(date?: string | null, withTime = false): string { + if (!date) return '—' + return new Date(date).toLocaleString('de-DE', withTime + ? { dateStyle: 'medium', timeStyle: 'short' } + : { dateStyle: 'medium' }) +} + +/** + * ISO-Datumsstring in YYYY-MM-DD für . + */ +export function toDateInputValue(date?: string | null): string { + if (!date) return '' + return new Date(date).toISOString().slice(0, 10) +} + +/** + * Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d"). + * Optionaler Fallback, wenn date fehlt. + */ +export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string { + if (!date) return fallback + const diffMs = Date.now() - new Date(date).getTime() + const mins = Math.max(0, Math.round(diffMs / 60000)) + if (mins < 1) return 'gerade eben' + if (mins < 60) return `vor ${mins} min` + const hours = Math.round(mins / 60) + if (hours < 24) return `vor ${hours} h` + const days = Math.round(hours / 24) + return `vor ${days} d` +} + +/** + * Minuten seit einem Datum (oder Infinity falls kein Datum). + */ +export function minutesSince(dateStr?: string | null): number { + if (!dateStr) return Infinity + return (Date.now() - new Date(dateStr).getTime()) / 60000 +} + +/** + * Stunden seit einem Datum (gerundet). + */ +export function hoursSince(dateStr: string): number { + const now = Date.now() + const then = new Date(dateStr).getTime() + return Math.round((now - then) / 3600000) +} diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index fa45bce..c343e1b 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -20,6 +20,8 @@ import { useTaskStore, type DashboardTaskDto } from '../stores/tasks' import { useLiveSyncStore } from '../stores/liveSync' import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool' import BoardCard from '../components/board/BoardCard.vue' +import StatusPill from '../components/ui/StatusPill.vue' +import { formatDate, toDateInputValue, relativeTime, minutesSince, hoursSince } from '../composables/useFormatDate' /** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt. * Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */ @@ -186,32 +188,20 @@ async function onDrop(e: DragEvent, targetState: string) { } /* ── Helpers ──────────────────────────────────────── */ -function statusTone(state: string): string { - switch (state.toLowerCase()) { - case 'done': return 'is-done' - case 'blocked': return 'is-blocked' - case 'review': return 'is-review' - case 'in progress': return 'is-progress' - default: return 'is-backlog' - } +/** Map state string to StatusPill variant */ +function statusPillVariant(state: string): 'backlog' | 'progress' | 'review' | 'blocked' | 'done' { + const s = state.toLowerCase() + if (s === 'done') return 'done' + if (s === 'blocked') return 'blocked' + if (s === 'review') return 'review' + if (s === 'in progress') return 'progress' + return 'backlog' } function stateLabel(state: string): string { return state === 'Backlog' ? 'Offen' : state } -function formatDate(date?: string | null, withTime = false): string { - if (!date) return '—' - return new Date(date).toLocaleString('de-DE', withTime - ? { dateStyle: 'medium', timeStyle: 'short' } - : { dateStyle: 'medium' }) -} - -function toDateInputValue(date?: string | null): string { - if (!date) return '' - return new Date(date).toISOString().slice(0, 10) -} - function flattenBoard() { const masters = [ ...taskStore.board.offen, @@ -246,11 +236,6 @@ const ballFilters = [ { key: 'stalled', label: 'Hängt' }, ] as const -function minutesSince(dateStr?: string | null): number { - if (!dateStr) return Infinity - return (Date.now() - new Date(dateStr).getTime()) / 60000 -} - function isStalledTask(t: DashboardTaskDto): boolean { if (t.state.toLowerCase() !== 'in progress') return false return minutesSince(t.lastActivityAt ?? t.updatedAt) > STALL_THRESHOLD_MIN @@ -319,23 +304,7 @@ function expectedFromLabel(expected: string | null | undefined): string { return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected } -function hoursSince(dateStr: string): number { - const now = Date.now() - const then = new Date(dateStr).getTime() - return Math.round((now - then) / 3600000) -} -function relativeTime(date?: string | null): string { - if (!date) return 'keine Updates' - const diffMs = Date.now() - new Date(date).getTime() - const mins = Math.max(0, Math.round(diffMs / 60000)) - if (mins < 1) return 'gerade eben' - if (mins < 60) return `vor ${mins} min` - const hours = Math.round(mins / 60) - if (hours < 24) return `vor ${hours} h` - const days = Math.round(hours / 24) - return `vor ${days} d` -} function childStatusSummary(taskId: string): string { const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId) @@ -807,7 +776,7 @@ onUnmounted(() => {
Task Board / - {{ stateLabel(selectedTask.state) }} + {{ stateLabel(selectedTask.state) }}