feat: add complete Vue 3 frontend in web/ directory

Full Vue 3 + Vite + TypeScript + Tailwind SPA replacing the vanilla JS static frontend.
Includes router, Pinia stores (auth/tasks/calendar/devops), axios API client with all
endpoints, UI components (Button/Card/Dialog/Badge/Input/etc), calendar grid with
lane-packing algorithm and DnD support, SSE live feed, and all 11 views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 18:52:43 +01:00
parent 1071ac2f4d
commit ff52d502b8
70 changed files with 5081 additions and 0 deletions

21
web/.eslintrc.cjs Normal file
View file

@ -0,0 +1,21 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@typescript-eslint/recommended',
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
},
}

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/cc-dashboard/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CC Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

52
web/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "cc-dashboard-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.ts,.tsx",
"typecheck": "vue-tsc --noEmit",
"test:unit": "vitest run"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.3.0",
"pinia": "^2.2.0",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.0",
"@tanstack/vue-query": "^5.51.0",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.1.3",
"zod": "^3.23.8",
"vee-validate": "^4.13.2",
"@vee-validate/zod": "^4.13.2",
"vue-sonner": "^1.1.4",
"marked": "^12.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"radix-vue": "^1.9.9",
"@radix-icons/vue": "^1.0.0",
"lucide-vue-next": "^0.427.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^5.4.0",
"typescript": "^5.5.0",
"vue-tsc": "^2.1.0",
"tailwindcss": "^3.4.10",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"vitest": "^2.0.5",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^14.12.3",
"prettier": "^3.3.3"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

17
web/src/App.vue Normal file
View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { Toaster } from 'vue-sonner'
</script>
<template>
<RouterView />
<Toaster
position="top-right"
:toast-options="{
style: {
background: 'hsl(var(--card))',
color: 'hsl(var(--card-foreground))',
border: '1px solid hsl(var(--border))',
},
}"
/>
</template>

34
web/src/api/client.ts Normal file
View file

@ -0,0 +1,34 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: '/cc-dashboard',
headers: {
'Content-Type': 'application/json',
},
})
// We set up interceptors lazily after stores are initialized
export function setupInterceptors(
getToken: () => string | null,
onUnauthorized: () => void
) {
apiClient.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
onUnauthorized()
}
return Promise.reject(error)
}
)
}
export default apiClient

View file

@ -0,0 +1,16 @@
import apiClient from '@/api/client'
import type { UserOut, ApiKey } from '@/types'
export const adminApi = {
users: () =>
apiClient.get<UserOut[]>('/api/admin/users'),
keys: () =>
apiClient.get<ApiKey[]>('/api/keys'),
createKey: (payload: { label: string }) =>
apiClient.post<{ key: string; id: string; prefix: string }>('/api/keys', payload),
revokeKey: (id: string) =>
apiClient.delete(`/api/keys/${id}`),
}

View file

@ -0,0 +1,23 @@
import apiClient from '@/api/client'
import type { Budget } from '@/types'
export interface BudgetPayload {
project_id: string
budget_hours: number
period_start: string
period_end: string
}
export const budgetsApi = {
list: () =>
apiClient.get<Budget[]>('/api/budgets'),
create: (payload: BudgetPayload) =>
apiClient.post<Budget>('/api/budgets', payload),
update: (id: string, payload: Partial<BudgetPayload>) =>
apiClient.patch<Budget>(`/api/budgets/${id}`, payload),
remove: (id: string) =>
apiClient.delete(`/api/budgets/${id}`),
}

View file

@ -0,0 +1,44 @@
import apiClient from '@/api/client'
import type {
KpiSummary,
ProjectSummary,
MonthlyDataPoint,
DowDataPoint,
ToolUsage,
ActivityEvent,
CalendarBlock,
} from '@/types'
export interface DashboardParams {
from?: string
to?: string
}
export const dashboardApi = {
summary: (params: DashboardParams) =>
apiClient.get<KpiSummary>('/api/dashboard/summary', { params }),
projects: (params: DashboardParams) =>
apiClient.get<ProjectSummary[]>('/api/dashboard/projects', { params }),
timeline: (params: DashboardParams) =>
apiClient.get<MonthlyDataPoint[]>('/api/dashboard/timeline', { params }),
monthly: (params: DashboardParams) =>
apiClient.get<MonthlyDataPoint[]>('/api/dashboard/monthly', { params }),
dow: (params: DashboardParams) =>
apiClient.get<DowDataPoint[]>('/api/dashboard/dow', { params }),
tools: (params: DashboardParams) =>
apiClient.get<ToolUsage[]>('/api/dashboard/tools', { params }),
activity: (params: DashboardParams & { limit?: number }) =>
apiClient.get<ActivityEvent[]>('/api/dashboard/activity', { params }),
calendar: (params: { from: string; to: string; view: 'week' | 'day' }) =>
apiClient.get<CalendarBlock[]>('/api/dashboard/calendar', { params }),
project: (id: string, params?: DashboardParams) =>
apiClient.get('/api/dashboard/project/' + id, { params }),
}

View file

@ -0,0 +1,27 @@
import apiClient from '@/api/client'
import type { AzureIntegration, AzureWorkItem } from '@/types'
export interface IntegrationPayload {
org: string
project: string
pat: string
}
export const devopsApi = {
getIntegration: () =>
apiClient.get<AzureIntegration>('/api/devops/integration'),
saveIntegration: (payload: IntegrationPayload) =>
apiClient.put<AzureIntegration>('/api/devops/integration', payload),
deleteIntegration: () =>
apiClient.delete('/api/devops/integration'),
sync: () =>
apiClient.post('/api/devops/sync'),
workItems: (state?: string) =>
apiClient.get<AzureWorkItem[]>('/api/devops/work-items', {
params: state ? { state } : undefined,
}),
}

View file

@ -0,0 +1,15 @@
export function downloadCsv(from: string, to: string): void {
const url = `/cc-dashboard/api/export/timesheet.csv?from=${from}&to=${to}`
const a = document.createElement('a')
a.href = url
a.download = `timesheet-${from}-${to}.csv`
a.click()
}
export function downloadIcs(from: string, to: string): void {
const url = `/cc-dashboard/api/export/timesheet.ics?from=${from}&to=${to}`
const a = document.createElement('a')
a.href = url
a.download = `timesheet-${from}-${to}.ics`
a.click()
}

View file

@ -0,0 +1,24 @@
import apiClient from '@/api/client'
import type { ManualEntry } from '@/types'
export interface ManualEntryPayload {
project_id?: string | null
start_at: string
end_at: string
notes?: string
tag_ids?: string[]
}
export const manualEntriesApi = {
list: (params?: { from?: string; to?: string }) =>
apiClient.get<ManualEntry[]>('/api/manual-entries', { params }),
create: (payload: ManualEntryPayload) =>
apiClient.post<ManualEntry>('/api/manual-entries', payload),
update: (id: string, payload: Partial<ManualEntryPayload>) =>
apiClient.patch<ManualEntry>(`/api/manual-entries/${id}`, payload),
remove: (id: string) =>
apiClient.delete(`/api/manual-entries/${id}`),
}

View file

@ -0,0 +1,13 @@
import apiClient from '@/api/client'
import type { AiReport } from '@/types'
export const reportsApi = {
list: () =>
apiClient.get<AiReport[]>('/api/reports'),
get: (id: string) =>
apiClient.get<AiReport>(`/api/reports/${id}`),
generate: (payload: { type: 'daily' | 'weekly'; period_date: string }) =>
apiClient.post<AiReport>('/api/reports/generate', payload),
}

View file

@ -0,0 +1,27 @@
import apiClient from '@/api/client'
import type { TagBrief } from '@/types'
export interface TagPayload {
name: string
color_hex: string
}
export const tagsApi = {
list: () =>
apiClient.get<TagBrief[]>('/api/tags'),
create: (payload: TagPayload) =>
apiClient.post<TagBrief>('/api/tags', payload),
update: (id: string, payload: Partial<TagPayload>) =>
apiClient.patch<TagBrief>(`/api/tags/${id}`, payload),
remove: (id: string) =>
apiClient.delete(`/api/tags/${id}`),
addToTask: (taskId: string, tagId: string) =>
apiClient.post(`/api/tasks/${taskId}/tags/${tagId}`),
removeFromTask: (taskId: string, tagId: string) =>
apiClient.delete(`/api/tasks/${taskId}/tags/${tagId}`),
}

View file

@ -0,0 +1,56 @@
import apiClient from '@/api/client'
import type { Task, TaskBlock } from '@/types'
export interface TaskCreatePayload {
title: string
notes?: string
planned_date: string
estimate_hours?: number
status?: Task['status']
priority?: Task['priority']
project_id?: string | null
azure_work_item_id?: string | null
}
export interface TaskUpdatePayload extends Partial<TaskCreatePayload> {
sort_index?: number
}
export interface BlockCreatePayload {
start_at: string
end_at: string
}
export interface BlockUpdatePayload extends Partial<BlockCreatePayload> {}
export const tasksApi = {
list: (params?: { date?: string; project_id?: string }) =>
apiClient.get<Task[]>('/api/tasks', { params }),
get: (id: string) =>
apiClient.get<Task>(`/api/tasks/${id}`),
create: (payload: TaskCreatePayload) =>
apiClient.post<Task>('/api/tasks', payload),
update: (id: string, payload: TaskUpdatePayload) =>
apiClient.patch<Task>(`/api/tasks/${id}`, payload),
remove: (id: string) =>
apiClient.delete(`/api/tasks/${id}`),
complete: (id: string) =>
apiClient.post<Task>(`/api/tasks/${id}/complete`),
blocks: (taskId: string) =>
apiClient.get<TaskBlock[]>(`/api/tasks/${taskId}/blocks`),
createBlock: (taskId: string, payload: BlockCreatePayload) =>
apiClient.post<TaskBlock>(`/api/tasks/${taskId}/blocks`, payload),
updateBlock: (id: string, payload: BlockUpdatePayload) =>
apiClient.patch<TaskBlock>(`/api/tasks/blocks/${id}`, payload),
deleteBlock: (id: string) =>
apiClient.delete(`/api/tasks/blocks/${id}`),
}

View file

@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CalendarBlock } from '@/types'
import { hslBgFromHue, hslBorderFromHue } from '@/lib/color'
import { formatTime, formatDuration } from '@/lib/utils'
const props = defineProps<{
block: CalendarBlock
lane: number
totalLanes: number
top: number
height: number
resizeEnd?: string | null
}>()
const emit = defineEmits<{
resizeStart: [event: MouseEvent]
click: [block: CalendarBlock]
}>()
const effectiveEnd = computed(() =>
props.resizeEnd ? new Date(props.resizeEnd) : new Date(props.block.end_at)
)
const effectiveHeight = computed(() => {
if (!props.resizeEnd) return props.height
const durationMs = effectiveEnd.value.getTime() - new Date(props.block.start_at).getTime()
const durationMin = durationMs / 60000
return Math.max(durationMin * (40 / 30), 20)
})
const duration = computed(() => {
const durationMs = effectiveEnd.value.getTime() - new Date(props.block.start_at).getTime()
return formatDuration(durationMs / 3600000)
})
const style = computed(() => {
const width = `calc(${100 / props.totalLanes}% - 2px)`
const left = `calc(${(props.lane / props.totalLanes) * 100}% + 1px)`
return {
top: `${props.top}px`,
height: `${effectiveHeight.value}px`,
width,
left,
backgroundColor: hslBgFromHue(props.block.color_hue),
borderColor: hslBorderFromHue(props.block.color_hue),
}
})
const isShort = computed(() => effectiveHeight.value < 40)
</script>
<template>
<div
class="absolute rounded overflow-hidden cursor-pointer select-none group"
:class="{
'border-2': block.kind === 'session',
'border-2 border-dashed opacity-80': block.kind === 'planned',
'border-2 calendar-block--manual': block.kind === 'manual',
}"
:style="style"
@click="emit('click', block)"
>
<!-- Content -->
<div class="px-1.5 py-1 h-full flex flex-col text-white overflow-hidden">
<p class="text-xs font-semibold leading-tight truncate">{{ block.display_name }}</p>
<p v-if="!isShort && block.job_number" class="text-xs opacity-75 truncate">
{{ block.job_number }}
</p>
<p v-if="!isShort" class="text-xs opacity-75 mt-auto">{{ duration }}</p>
</div>
<!-- Resize handle -->
<div
class="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize opacity-0 group-hover:opacity-100 flex items-center justify-center"
@mousedown.stop="emit('resizeStart', $event)"
>
<div class="w-8 h-0.5 bg-white/60 rounded" />
</div>
</div>
</template>
<style scoped>
.calendar-block--manual {
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
rgba(255, 255, 255, 0.1) 3px,
rgba(255, 255, 255, 0.1) 6px
);
}
</style>

View file

@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCalendarStore } from '@/stores/calendar'
import { useCalendarDnD } from '@/composables/useCalendarDnD'
import { packLanes, blockTopPx, blockHeightPx } from '@/lib/calendar'
import { isoDateStr } from '@/lib/utils'
import { format } from 'date-fns'
import CalendarBlockComp from './CalendarBlock.vue'
import type { CalendarBlock } from '@/types'
const DAY_START = 7
const DAY_END = 19
const SLOT_HEIGHT = 40 // 40px per 30-min slot
const HOURS = Array.from({ length: DAY_END - DAY_START + 1 }, (_, i) => DAY_START + i)
const calendarStore = useCalendarStore()
const dnd = useCalendarDnD()
const emit = defineEmits<{
blockClick: [block: CalendarBlock]
}>()
const days = computed(() => {
if (calendarStore.view === 'week') return calendarStore.weekDays
return [calendarStore.currentDate]
})
const today = isoDateStr(new Date())
function getBlocksWithLanes(day: Date) {
const dayBlocks = calendarStore.getBlocksForDay(day)
return packLanes(dayBlocks)
}
function getBlockTop(block: CalendarBlock): number {
return blockTopPx(new Date(block.start_at), DAY_START)
}
function getBlockHeight(block: CalendarBlock): number {
return blockHeightPx(new Date(block.start_at), new Date(block.end_at))
}
function isResizing(block: CalendarBlock): boolean {
return dnd.resizingBlock.value?.id === block.id
}
function formatHour(h: number): string {
if (h === 12) return '12 PM'
if (h > 12) return `${h - 12} PM`
return `${h} AM`
}
</script>
<template>
<div class="flex overflow-auto h-full">
<!-- Time gutter -->
<div class="w-12 shrink-0 relative" :style="{ height: `${(DAY_END - DAY_START + 1) * SLOT_HEIGHT * 2}px` }">
<div
v-for="hour in HOURS"
:key="hour"
class="absolute right-2 text-xs text-muted-foreground"
:style="{ top: `${(hour - DAY_START) * SLOT_HEIGHT * 2 - 6}px` }"
>
{{ formatHour(hour) }}
</div>
</div>
<!-- Day columns -->
<div class="flex flex-1 gap-px min-w-0">
<div
v-for="day in days"
:key="isoDateStr(day)"
class="flex-1 relative border-l border-border"
:class="{ 'bg-primary/5': isoDateStr(day) === today }"
:style="{ height: `${(DAY_END - DAY_START) * SLOT_HEIGHT * 2}px` }"
@dragover="dnd.onDragOver(day, $event)"
@dragleave="dnd.onDragLeave()"
@drop="dnd.onDrop(day, $event)"
>
<!-- Day header (week view) -->
<div
v-if="calendarStore.view === 'week'"
:class="[
'sticky top-0 z-10 text-center py-1 text-xs font-medium border-b border-border bg-background',
isoDateStr(day) === today ? 'text-primary' : 'text-muted-foreground',
]"
>
<div>{{ format(day, 'EEE') }}</div>
<div
:class="[
'inline-flex h-6 w-6 mx-auto items-center justify-center rounded-full text-sm',
isoDateStr(day) === today ? 'bg-primary text-primary-foreground' : '',
]"
>
{{ format(day, 'd') }}
</div>
</div>
<!-- Hour lines -->
<div
v-for="hour in HOURS"
:key="hour"
class="absolute left-0 right-0 border-t border-border/40"
:style="{ top: `${(hour - DAY_START) * SLOT_HEIGHT * 2}px` }"
/>
<div
v-for="hour in HOURS.slice(0, -1)"
:key="`half-${hour}`"
class="absolute left-0 right-0 border-t border-border/20"
:style="{ top: `${(hour - DAY_START) * SLOT_HEIGHT * 2 + SLOT_HEIGHT}px` }"
/>
<!-- Drop highlight -->
<div
v-if="dnd.dragOverDay.value === isoDateStr(day)"
class="absolute inset-0 bg-primary/10 pointer-events-none z-0"
/>
<!-- Calendar blocks -->
<CalendarBlockComp
v-for="{ block, lane, totalLanes } in getBlocksWithLanes(day)"
:key="block.id"
:block="block"
:lane="lane"
:total-lanes="totalLanes"
:top="getBlockTop(block)"
:height="getBlockHeight(block)"
:resize-end="isResizing(block) ? dnd.resizePreviewEnd.value : null"
@click="emit('blockClick', block)"
@resize-start="dnd.onResizeStart(block, $event)"
/>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCalendarStore } from '@/stores/calendar'
import Button from '@/components/ui/Button.vue'
import { format } from 'date-fns'
const calendarStore = useCalendarStore()
const dateLabel = computed(() => {
if (calendarStore.view === 'week') {
const days = calendarStore.weekDays
if (!days.length) return ''
const start = days[0]
const end = days[6]
if (start.getMonth() === end.getMonth()) {
return `${format(start, 'MMM d')} ${format(end, 'd, yyyy')}`
}
return `${format(start, 'MMM d')} ${format(end, 'MMM d, yyyy')}`
} else {
return format(calendarStore.currentDate, 'EEEE, MMMM d, yyyy')
}
})
async function navigate(dir: 'prev' | 'next') {
if (dir === 'prev') calendarStore.navigatePrev()
else calendarStore.navigateNext()
await calendarStore.fetchCurrentView()
}
async function goToday() {
calendarStore.goToToday()
await calendarStore.fetchCurrentView()
}
async function setView(v: 'week' | 'day') {
calendarStore.setView(v)
await calendarStore.fetchCurrentView()
}
</script>
<template>
<div class="flex items-center gap-2 flex-wrap">
<!-- Navigation -->
<div class="flex items-center gap-1">
<Button variant="outline" size="sm" @click="navigate('prev')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Button variant="outline" size="sm" @click="goToday">Today</Button>
<Button variant="outline" size="sm" @click="navigate('next')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
<!-- Date label -->
<span class="text-sm font-medium text-foreground flex-1 min-w-0 truncate">
{{ dateLabel }}
</span>
<!-- Loading indicator -->
<div v-if="calendarStore.loading" class="text-xs text-muted-foreground">Loading...</div>
<!-- View toggle -->
<div class="flex items-center rounded-md border border-border overflow-hidden">
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
calendarStore.view === 'day'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
@click="setView('day')"
>
Day
</button>
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
calendarStore.view === 'week'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
@click="setView('week')"
>
Week
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { useCalendarStore } from '@/stores/calendar'
import { useCalendarDnD } from '@/composables/useCalendarDnD'
import { isoDateStr, formatDuration } from '@/lib/utils'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import type { Task } from '@/types'
const tasksStore = useTasksStore()
const calendarStore = useCalendarStore()
const dnd = useCalendarDnD()
const emit = defineEmits<{
createTask: []
}>()
const selectedDate = computed(() => isoDateStr(calendarStore.currentDate))
onMounted(() => {
tasksStore.fetchForDate(selectedDate.value)
})
const statusVariant = (status: Task['status']) => {
const map: Record<Task['status'], 'default' | 'secondary' | 'success' | 'warning' | 'outline'> = {
todo: 'outline',
doing: 'default',
done: 'success',
cancelled: 'secondary',
}
return map[status]
}
const priorityColor = (p: number) => {
if (p >= 4) return 'bg-red-500'
if (p === 3) return 'bg-amber-500'
return 'bg-emerald-500'
}
const planTotals = computed(() => {
const byProject: Record<string, { name: string; planned: number; actual: number }> = {}
for (const task of tasksStore.tasks) {
const key = task.project_id ?? '_none'
if (!byProject[key]) {
byProject[key] = { name: task.project_id ? key : 'No Project', planned: 0, actual: 0 }
}
byProject[key].planned += task.estimate_hours ?? 0
byProject[key].actual += task.actual_hours ?? 0
}
return Object.values(byProject)
})
</script>
<template>
<div class="flex flex-col h-full bg-card border-l border-border">
<!-- Header -->
<div class="p-3 border-b border-border flex items-center justify-between shrink-0">
<h3 class="text-sm font-semibold text-foreground">Planner</h3>
<Button size="sm" variant="ghost" @click="emit('createTask')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</Button>
</div>
<!-- Task list -->
<div class="flex-1 overflow-y-auto p-2 space-y-1.5">
<div v-if="tasksStore.loading" class="text-xs text-muted-foreground p-2">Loading...</div>
<div
v-else-if="tasksStore.tasks.length === 0"
class="text-xs text-muted-foreground p-2 text-center"
>
No tasks for today
</div>
<div
v-for="task in tasksStore.tasks"
:key="task.id"
class="rounded-md border border-border bg-background p-2 cursor-grab active:cursor-grabbing hover:border-primary/50 transition-colors"
draggable="true"
@dragstart="dnd.onDragStart(task, $event)"
>
<div class="flex items-start gap-2">
<!-- Priority dot -->
<div
:class="['h-2 w-2 rounded-full mt-1.5 shrink-0', priorityColor(task.priority)]"
/>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-foreground leading-tight truncate">
{{ task.title }}
</p>
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge :variant="statusVariant(task.status)" class="text-xs py-0">
{{ task.status }}
</Badge>
<span v-if="task.estimate_hours" class="text-xs text-muted-foreground">
{{ formatDuration(task.estimate_hours) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Summary -->
<div v-if="planTotals.length" class="p-3 border-t border-border shrink-0">
<p class="text-xs font-medium text-muted-foreground mb-2">Plan vs Actual</p>
<div class="space-y-1">
<div
v-for="row in planTotals"
:key="row.name"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground truncate max-w-[100px]">{{ row.name }}</span>
<span class="text-foreground">
{{ formatDuration(row.planned) }} / {{ formatDuration(row.actual) }}
</span>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
defineProps<{
label: string
value: string | number
icon?: string
trend?: number
description?: string
loading?: boolean
}>()
</script>
<template>
<Card class="relative overflow-hidden">
<CardContent class="p-5">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<p class="text-xs text-muted-foreground font-medium uppercase tracking-wide truncate">
{{ label }}
</p>
<div class="mt-1">
<div v-if="loading" class="h-8 w-24 bg-muted animate-pulse rounded" />
<p v-else class="text-2xl font-bold text-foreground">{{ value }}</p>
</div>
<p v-if="description" class="text-xs text-muted-foreground mt-1 truncate">
{{ description }}
</p>
</div>
<!-- Icon -->
<div
v-if="icon"
class="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"
>
<!-- Clock -->
<svg v-if="icon === 'clock'" class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Calendar -->
<svg v-else-if="icon === 'calendar'" class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<!-- Folder -->
<svg v-else-if="icon === 'folder'" class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<!-- Trending up -->
<svg v-else-if="icon === 'trending-up'" class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<!-- Git commit -->
<svg v-else-if="icon === 'git'" class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
<path stroke-linecap="round" stroke-width="2" d="M2 12h6M16 12h6" />
</svg>
<!-- Star -->
<svg v-else class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
</div>
<!-- Trend indicator -->
<div v-if="trend !== undefined" class="mt-3 flex items-center gap-1 text-xs">
<svg
:class="trend >= 0 ? 'text-emerald-400' : 'text-red-400'"
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="trend >= 0"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span :class="trend >= 0 ? 'text-emerald-400' : 'text-red-400'">
{{ Math.abs(trend) }}%
</span>
<span class="text-muted-foreground">vs last period</span>
</div>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import Sidebar from './Sidebar.vue'
import TopBar from './TopBar.vue'
const route = useRoute()
const sidebarOpen = ref(false)
const pageTitle = computed(() => {
const routeToTitle: Record<string, string> = {
dashboard: 'Dashboard',
calendar: 'Calendar',
planner: 'Planner',
projects: 'Projects',
'project-detail': 'Project Details',
live: 'Live Feed',
reports: 'AI Reports',
keys: 'API Keys',
settings: 'Settings',
admin: 'Admin',
}
return routeToTitle[route.name as string] ?? 'CC Dashboard'
})
</script>
<template>
<div class="h-screen flex overflow-hidden bg-background">
<!-- Mobile backdrop -->
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-20 bg-black/60 lg:hidden"
@click="sidebarOpen = false"
/>
</Transition>
<!-- Sidebar - always visible on desktop, slide-in on mobile -->
<div
:class="[
'fixed inset-y-0 left-0 z-30 w-60 transform transition-transform duration-300 lg:relative lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
]"
>
<Sidebar @close="sidebarOpen = false" />
</div>
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<TopBar
:title="pageTitle"
:sidebar-open="sidebarOpen"
@toggle-sidebar="sidebarOpen = !sidebarOpen"
/>
<main class="flex-1 overflow-y-auto">
<RouterView />
</main>
</div>
</div>
</template>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const emit = defineEmits<{ close: [] }>()
interface NavItem {
name: string
path: string
icon: string
adminOnly?: boolean
}
const navItems: NavItem[] = [
{ name: 'Dashboard', path: '/', icon: 'grid' },
{ name: 'Calendar', path: '/calendar', icon: 'calendar' },
{ name: 'Planner', path: '/planner', icon: 'check-square' },
{ name: 'Projects', path: '/projects', icon: 'folder' },
{ name: 'Live Feed', path: '/live', icon: 'activity' },
{ name: 'Reports', path: '/reports', icon: 'file-text' },
{ name: 'Keys', path: '/keys', icon: 'key' },
{ name: 'Settings', path: '/settings', icon: 'settings' },
{ name: 'Admin', path: '/admin', icon: 'shield', adminOnly: true },
]
const visibleItems = computed(() =>
navItems.filter((item) => !item.adminOnly || authStore.isAdmin)
)
function isActive(path: string): boolean {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<template>
<aside class="flex flex-col h-full bg-slate-900 dark:bg-slate-900 border-r border-border">
<!-- Logo -->
<div class="h-14 flex items-center px-4 border-b border-border shrink-0">
<div class="flex items-center gap-2">
<div class="h-7 w-7 rounded-md bg-primary flex items-center justify-center">
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<span class="font-bold text-sm text-foreground">CC Dashboard</span>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
<RouterLink
v-for="item in visibleItems"
:key="item.path"
:to="item.path"
:class="[
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-primary/20 text-primary'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
]"
@click="emit('close')"
>
<!-- Icons -->
<svg
v-if="item.icon === 'grid'"
class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<svg v-else-if="item.icon === 'calendar'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<svg v-else-if="item.icon === 'check-square'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<svg v-else-if="item.icon === 'folder'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<svg v-else-if="item.icon === 'activity'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else-if="item.icon === 'file-text'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg v-else-if="item.icon === 'key'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<svg v-else-if="item.icon === 'settings'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<svg v-else-if="item.icon === 'shield'" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>{{ item.name }}</span>
</RouterLink>
</nav>
<!-- User info at bottom -->
<div class="p-4 border-t border-border shrink-0">
<div class="flex items-center gap-2 text-xs text-slate-400">
<div class="h-2 w-2 rounded-full bg-emerald-500"></div>
<span class="truncate">{{ authStore.user?.username ?? authStore.user?.email }}</span>
</div>
</div>
</aside>
</template>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import Avatar from '@/components/ui/Avatar.vue'
import Button from '@/components/ui/Button.vue'
import { toast } from 'vue-sonner'
defineProps<{
title?: string
sidebarOpen?: boolean
}>()
const emit = defineEmits<{
toggleSidebar: []
toggleDark: []
}>()
const authStore = useAuthStore()
const router = useRouter()
async function handleLogout() {
await authStore.logout()
toast.success('Logged out')
router.push({ name: 'login' })
}
function toggleDark() {
document.documentElement.classList.toggle('dark')
emit('toggleDark')
}
</script>
<template>
<header class="h-14 border-b border-border bg-card flex items-center px-4 gap-4 shrink-0">
<!-- Mobile hamburger -->
<Button variant="ghost" size="icon" class="lg:hidden" @click="emit('toggleSidebar')">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
<!-- Page title -->
<h1 class="text-base font-semibold text-foreground flex-1">{{ title ?? 'CC Dashboard' }}</h1>
<!-- Actions slot -->
<slot name="actions" />
<!-- Dark mode toggle -->
<Button variant="ghost" size="icon" @click="toggleDark">
<svg class="h-4 w-4 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg class="h-4 w-4 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</Button>
<!-- User menu -->
<div class="flex items-center gap-2">
<Avatar :name="authStore.user?.username ?? authStore.user?.email" size="sm" />
<Button variant="ghost" size="sm" class="text-xs text-muted-foreground" @click="handleLogout">
Sign out
</Button>
</div>
</header>
</template>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import type { Task } from '@/types'
import Badge from '@/components/ui/Badge.vue'
import { formatDuration } from '@/lib/utils'
const props = defineProps<{
task: Task
draggable?: boolean
}>()
const emit = defineEmits<{
edit: [task: Task]
complete: [task: Task]
delete: [task: Task]
}>()
const statusVariant = (status: Task['status']) => {
const map: Record<Task['status'], 'default' | 'secondary' | 'success' | 'warning' | 'outline'> = {
todo: 'outline',
doing: 'default',
done: 'success',
cancelled: 'secondary',
}
return map[status]
}
const priorityLabel = (p: number): string => {
const labels = ['', 'Low', 'Medium', 'High', 'Critical', 'Blocker']
return labels[p] ?? 'Unknown'
}
const priorityColor = (p: number) => {
if (p >= 4) return 'bg-red-500'
if (p === 3) return 'bg-amber-500'
return 'bg-emerald-500'
}
</script>
<template>
<div
:draggable="draggable"
class="rounded-lg border border-border bg-card p-3 hover:border-primary/50 transition-colors cursor-pointer group"
@click="emit('edit', task)"
>
<div class="flex items-start gap-2">
<!-- Priority dot -->
<div
:class="['h-2 w-2 rounded-full mt-1.5 shrink-0', priorityColor(task.priority)]"
:title="priorityLabel(task.priority)"
/>
<div class="flex-1 min-w-0">
<!-- Title -->
<p class="text-sm font-medium text-foreground leading-tight truncate">{{ task.title }}</p>
<!-- Tags -->
<div v-if="task.tags.length" class="flex items-center gap-1 mt-1 flex-wrap">
<span
v-for="tag in task.tags"
:key="tag.id"
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
:style="{ background: `${tag.color_hex}22`, color: tag.color_hex }"
>
{{ tag.name }}
</span>
</div>
<!-- Meta row -->
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
<Badge :variant="statusVariant(task.status)" class="text-xs py-0">
{{ task.status }}
</Badge>
<span v-if="task.estimate_hours" class="text-xs text-muted-foreground">
~{{ formatDuration(task.estimate_hours) }}
</span>
<span v-if="task.actual_hours" class="text-xs text-emerald-400">
{{ formatDuration(task.actual_hours) }} actual
</span>
<span
v-if="task.azure_work_item_id"
class="text-xs text-blue-400 ml-auto"
title="Azure DevOps"
>
#{{ task.azure_work_item_id }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 shrink-0">
<button
v-if="task.status !== 'done'"
class="p-1 rounded hover:bg-emerald-500/20 text-emerald-400"
title="Mark done"
@click.stop="emit('complete', task)"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<button
class="p-1 rounded hover:bg-red-500/20 text-red-400"
title="Delete"
@click.stop="emit('delete', task)"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import Dialog from '@/components/ui/Dialog.vue'
import Input from '@/components/ui/Input.vue'
import Textarea from '@/components/ui/Textarea.vue'
import Select from '@/components/ui/Select.vue'
import Button from '@/components/ui/Button.vue'
import { useDevopsStore } from '@/stores/devops'
import type { Task } from '@/types'
import type { TaskCreatePayload, TaskUpdatePayload } from '@/api/endpoints/tasks'
const props = withDefaults(
defineProps<{
open: boolean
task?: Task | null
defaultDate?: string
}>(),
{ task: null }
)
const emit = defineEmits<{
close: []
save: [payload: TaskCreatePayload | TaskUpdatePayload]
}>()
const devopsStore = useDevopsStore()
const form = ref({
title: '',
notes: '',
planned_date: '',
estimate_hours: 1,
status: 'todo' as Task['status'],
priority: 3 as Task['priority'],
project_id: null as string | null,
azure_work_item_id: null as string | null,
})
watch(
() => props.open,
(open) => {
if (open) {
if (props.task) {
form.value = {
title: props.task.title,
notes: props.task.notes ?? '',
planned_date: props.task.planned_date ?? '',
estimate_hours: props.task.estimate_hours ?? 1,
status: props.task.status,
priority: props.task.priority,
project_id: props.task.project_id,
azure_work_item_id: props.task.azure_work_item_id,
}
} else {
form.value = {
title: '',
notes: '',
planned_date: props.defaultDate ?? '',
estimate_hours: 1,
status: 'todo',
priority: 3,
project_id: null,
azure_work_item_id: null,
}
}
// Load ADO work items if integration exists
if (devopsStore.integration && !devopsStore.workItems.length) {
devopsStore.fetchWorkItems('open')
}
}
},
{ immediate: true }
)
const saving = ref(false)
async function handleSave() {
if (!form.value.title.trim()) return
saving.value = true
try {
const payload: TaskCreatePayload = {
title: form.value.title,
notes: form.value.notes || undefined,
planned_date: form.value.planned_date,
estimate_hours: form.value.estimate_hours,
status: form.value.status,
priority: form.value.priority,
project_id: form.value.project_id || null,
azure_work_item_id: form.value.azure_work_item_id || null,
}
emit('save', payload)
} finally {
saving.value = false
}
}
</script>
<template>
<Dialog
:open="open"
:title="task ? 'Edit Task' : 'New Task'"
max-width="max-w-md"
@close="emit('close')"
>
<form class="space-y-4" @submit.prevent="handleSave">
<!-- Title -->
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Title *</label>
<Input v-model="form.title" placeholder="Task title..." :disabled="saving" />
</div>
<!-- Notes -->
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Notes</label>
<Textarea v-model="form.notes" placeholder="Additional notes..." :disabled="saving" />
</div>
<!-- Date + Estimate row -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Planned Date</label>
<Input v-model="form.planned_date" type="date" :disabled="saving" />
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Estimate (h)</label>
<Input
v-model="form.estimate_hours"
type="number"
min="0.25"
max="24"
step="0.25"
:disabled="saving"
/>
</div>
</div>
<!-- Status + Priority row -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Status</label>
<Select v-model="form.status" :disabled="saving">
<option value="todo">Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Priority</label>
<Select v-model="form.priority" :disabled="saving">
<option value="1">1 - Low</option>
<option value="2">2 - Normal</option>
<option value="3">3 - Medium</option>
<option value="4">4 - High</option>
<option value="5">5 - Critical</option>
</Select>
</div>
</div>
<!-- ADO Work Item -->
<div v-if="devopsStore.workItems.length" class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Azure DevOps Work Item</label>
<Select v-model="form.azure_work_item_id" :disabled="saving" placeholder="Link work item...">
<option v-for="wi in devopsStore.workItems" :key="wi.id" :value="String(wi.id)">
#{{ wi.id }} {{ wi.title }}
</option>
</Select>
</div>
</form>
<template #footer>
<Button variant="outline" :disabled="saving" @click="emit('close')">Cancel</Button>
<Button :loading="saving" @click="handleSave">
{{ task ? 'Update' : 'Create' }}
</Button>
</template>
</Dialog>
</template>

View file

@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Task } from '@/types'
import TaskCard from './TaskCard.vue'
const props = defineProps<{
tasks: Task[]
loading?: boolean
}>()
const emit = defineEmits<{
edit: [task: Task]
complete: [task: Task]
delete: [task: Task]
}>()
const grouped = computed(() => {
const groups: Record<string, Task[]> = {
doing: [],
todo: [],
done: [],
cancelled: [],
}
for (const task of props.tasks) {
groups[task.status]?.push(task)
}
return groups
})
const statusLabels: Record<string, string> = {
doing: 'In Progress',
todo: 'To Do',
done: 'Done',
cancelled: 'Cancelled',
}
</script>
<template>
<div class="space-y-6">
<div v-if="loading" class="text-sm text-muted-foreground py-4 text-center">Loading tasks...</div>
<template v-else>
<div
v-for="(group, status) in grouped"
:key="status"
v-show="group.length > 0"
>
<div class="flex items-center gap-2 mb-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{{ statusLabels[status] }}
</h3>
<span class="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full">
{{ group.length }}
</span>
</div>
<div class="space-y-2">
<TaskCard
v-for="task in group"
:key="task.id"
:task="task"
draggable
@edit="emit('edit', task)"
@complete="emit('complete', task)"
@delete="emit('delete', task)"
/>
</div>
</div>
<div
v-if="!props.tasks.length"
class="text-sm text-muted-foreground text-center py-8"
>
No tasks found
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
name?: string
src?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{ size: 'md' }
)
const initials = computed(() => {
if (!props.name) return '?'
return props.name
.split(' ')
.slice(0, 2)
.map((n) => n[0])
.join('')
.toUpperCase()
})
const hue = computed(() => {
if (!props.name) return 260
let h = 0
for (const c of props.name) h = (h * 31 + c.charCodeAt(0)) % 360
return h
})
</script>
<template>
<div
:class="[
'rounded-full flex items-center justify-center font-semibold text-white overflow-hidden shrink-0',
size === 'sm' ? 'h-7 w-7 text-xs' : size === 'lg' ? 'h-10 w-10 text-base' : 'h-8 w-8 text-sm',
]"
:style="!src ? `background: hsla(${hue}, 60%, 45%, 1)` : ''"
>
<img v-if="src" :src="src" :alt="name" class="h-full w-full object-cover" />
<span v-else>{{ initials }}</span>
</div>
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
class?: string
}>(),
{ variant: 'default' }
)
</script>
<template>
<span
:class="cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
'bg-primary text-primary-foreground': props.variant === 'default',
'bg-secondary text-secondary-foreground': props.variant === 'secondary',
'bg-destructive text-destructive-foreground': props.variant === 'destructive',
'border border-border text-foreground': props.variant === 'outline',
'bg-emerald-500/20 text-emerald-400': props.variant === 'success',
'bg-amber-500/20 text-amber-400': props.variant === 'warning',
},
props.class
)"
>
<slot />
</span>
</template>

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import Spinner from './Spinner.vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary' | 'link'
size?: 'sm' | 'md' | 'lg' | 'icon'
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
class?: string
}>(),
{
variant: 'default',
size: 'md',
loading: false,
disabled: false,
type: 'button',
}
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const classes = computed(() =>
cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
{
'bg-primary text-primary-foreground hover:bg-primary/90': props.variant === 'default',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground':
props.variant === 'outline',
'hover:bg-accent hover:text-accent-foreground': props.variant === 'ghost',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
props.variant === 'destructive',
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
props.variant === 'secondary',
'underline-offset-4 hover:underline text-primary': props.variant === 'link',
'h-8 px-3 text-xs': props.size === 'sm',
'h-10 px-4 py-2 text-sm': props.size === 'md',
'h-11 px-8 text-base': props.size === 'lg',
'h-9 w-9 p-0': props.size === 'icon',
},
props.class
)
)
</script>
<template>
<button
:class="classes"
:type="type"
:disabled="disabled || loading"
@click="emit('click', $event)"
>
<Spinner v-if="loading" size="sm" class="mr-2" />
<slot />
</button>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<div
:class="cn('rounded-lg border bg-card text-card-foreground shadow-sm', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<h3 :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)">
<slot />
</h3>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: boolean
disabled?: boolean
class?: string
id?: string
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
<template>
<div class="flex items-center space-x-2">
<input
:id="id"
type="checkbox"
:checked="modelValue"
:disabled="disabled"
:class="cn(
'h-4 w-4 rounded border-border text-primary',
'focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<label v-if="label" :for="id" class="text-sm font-medium leading-none cursor-pointer">
{{ label }}
</label>
</div>
</template>

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import Button from './Button.vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
description?: string
maxWidth?: string
}>(),
{ maxWidth: 'max-w-lg' }
)
const emit = defineEmits<{
close: []
}>()
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) {
emit('close')
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
/>
<!-- Dialog panel -->
<div
:class="['relative w-full bg-card border border-border rounded-lg shadow-xl z-10', maxWidth]"
role="dialog"
:aria-modal="true"
:aria-label="title"
>
<!-- Header -->
<div v-if="title || $slots.header" class="flex items-center justify-between p-6 pb-4">
<div>
<slot name="header">
<h2 class="text-lg font-semibold text-foreground">{{ title }}</h2>
<p v-if="description" class="text-sm text-muted-foreground mt-1">{{ description }}</p>
</slot>
</div>
<Button variant="ghost" size="icon" class="shrink-0" @click="emit('close')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
<!-- Body -->
<div class="px-6 pb-4">
<slot />
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="flex justify-end gap-2 px-6 pb-6">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: string | number
type?: string
placeholder?: string
disabled?: boolean
class?: string
id?: string
name?: string
autocomplete?: string
min?: string | number
max?: string | number
step?: string | number
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
}>()
</script>
<template>
<input
:id="id"
:name="name"
:type="type ?? 'text'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:autocomplete="autocomplete"
:min="min"
:max="max"
:step="step"
:class="cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@change="emit('change', ($event.target as HTMLInputElement).value)"
@blur="emit('blur', $event)"
@focus="emit('focus', $event)"
/>
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
value: number
max?: number
class?: string
color?: 'default' | 'success' | 'warning' | 'danger'
}>(),
{ max: 100, color: 'default' }
)
const pct = () => Math.min(100, Math.max(0, (props.value / props.max) * 100))
</script>
<template>
<div :class="cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', props.class)">
<div
class="h-full rounded-full transition-all duration-300"
:class="{
'bg-primary': color === 'default',
'bg-emerald-500': color === 'success',
'bg-amber-500': color === 'warning',
'bg-red-500': color === 'danger',
}"
:style="{ width: `${pct()}%` }"
/>
</div>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: string | number
disabled?: boolean
class?: string
id?: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
}>()
</script>
<template>
<select
:id="id"
:value="modelValue"
:disabled="disabled"
:class="cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option v-if="placeholder" value="" disabled :selected="!modelValue">
{{ placeholder }}
</option>
<slot />
</select>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
defineProps<{
size?: 'sm' | 'md' | 'lg'
class?: string
}>()
</script>
<template>
<svg
:class="[
'animate-spin text-current',
size === 'sm' ? 'h-3 w-3' : size === 'lg' ? 'h-6 w-6' : 'h-4 w-4',
$props.class,
]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: string
placeholder?: string
disabled?: boolean
rows?: number
class?: string
id?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:rows="rows ?? 3"
:class="cn(
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50 resize-none',
props.class
)"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>

View file

@ -0,0 +1,170 @@
import { ref } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { useCalendarStore } from '@/stores/calendar'
import { isoDateStr } from '@/lib/utils'
import { PX_PER_MIN, snapToGrid } from '@/lib/calendar'
import type { Task, CalendarBlock } from '@/types'
const DAY_START_HOUR = 7
export function useCalendarDnD() {
const tasksStore = useTasksStore()
const calendarStore = useCalendarStore()
const draggingTaskId = ref<string | null>(null)
const dragOverDay = ref<string | null>(null)
const resizingBlock = ref<CalendarBlock | null>(null)
const resizePreviewEnd = ref<string | null>(null)
// --- Drag from Planner ---
function onDragStart(task: Task, e: DragEvent) {
draggingTaskId.value = task.id
e.dataTransfer?.setData('task_id', task.id)
e.dataTransfer?.setData('estimate_hours', String(task.estimate_hours ?? 1))
}
function onDragOver(dayDate: Date, e: DragEvent) {
e.preventDefault()
dragOverDay.value = isoDateStr(dayDate)
}
function onDragLeave() {
dragOverDay.value = null
}
async function onDrop(dayDate: Date, e: DragEvent) {
e.preventDefault()
dragOverDay.value = null
draggingTaskId.value = null
const taskId = e.dataTransfer?.getData('task_id')
const estimateHours = parseFloat(e.dataTransfer?.getData('estimate_hours') ?? '1') || 1
if (!taskId) return
// Calculate start time from Y position relative to the grid container
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const relativeY = e.clientY - rect.top
const minutesFromDayStart = snapToGrid(relativeY / PX_PER_MIN, 15)
const clampedMinutes = Math.max(0, Math.min(minutesFromDayStart, 12 * 60)) // 7am - 7pm = 12 hours
const startDate = new Date(dayDate)
startDate.setHours(DAY_START_HOUR, 0, 0, 0)
startDate.setMinutes(startDate.getMinutes() + clampedMinutes)
const endDate = new Date(startDate)
endDate.setMinutes(endDate.getMinutes() + Math.round(estimateHours * 60))
const start_at = startDate.toISOString()
const end_at = endDate.toISOString()
// Optimistic update - create a temporary CalendarBlock
const tempId = `temp_${Date.now()}`
const optimisticBlock: CalendarBlock = {
kind: 'planned',
id: tempId,
project_id: null,
job_number: '',
display_name: 'Loading...',
start_at,
end_at,
title: '',
color_hue: 260,
tags: [],
task_id: taskId,
session_id: null,
manual_entry_id: null,
}
calendarStore.addBlock(optimisticBlock)
try {
await tasksStore.createBlock(taskId, { start_at, end_at })
// Refresh calendar to get real block data
await calendarStore.fetchCurrentView()
} catch (err) {
// Rollback optimistic update
calendarStore.removeBlock(tempId)
console.error('Failed to create task block:', err)
}
}
// --- Resize block ---
let resizeStartY = 0
let resizeOriginalEnd = ''
let resizeBlockRef: CalendarBlock | null = null
function onResizeStart(block: CalendarBlock, e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
resizingBlock.value = block
resizeBlockRef = block
resizeStartY = e.clientY
resizeOriginalEnd = block.end_at
resizePreviewEnd.value = block.end_at
const onMouseMove = (moveEvent: MouseEvent) => {
if (!resizeBlockRef) return
const deltaY = moveEvent.clientY - resizeStartY
const deltaMinutes = snapToGrid(deltaY / PX_PER_MIN, 15)
const originalEndMs = new Date(resizeOriginalEnd).getTime()
const newEndMs = originalEndMs + deltaMinutes * 60000
const minEndMs = new Date(resizeBlockRef.start_at).getTime() + 15 * 60000
resizePreviewEnd.value = new Date(Math.max(newEndMs, minEndMs)).toISOString()
}
const onMouseUp = async () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
if (!resizeBlockRef || !resizePreviewEnd.value) {
resizingBlock.value = null
return
}
const blockId = resizeBlockRef.id
const newEnd = resizePreviewEnd.value
// Only update if changed
if (newEnd === resizeOriginalEnd) {
resizingBlock.value = null
resizePreviewEnd.value = null
return
}
try {
if (resizeBlockRef.task_id) {
await tasksStore.updateBlock(blockId, { end_at: newEnd })
}
calendarStore.updateBlock({ ...resizeBlockRef, end_at: newEnd })
} catch (err) {
console.error('Failed to resize block:', err)
// Revert
calendarStore.updateBlock({ ...resizeBlockRef, end_at: resizeOriginalEnd })
}
resizingBlock.value = null
resizePreviewEnd.value = null
resizeBlockRef = null
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
return {
draggingTaskId,
dragOverDay,
resizingBlock,
resizePreviewEnd,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onResizeStart,
}
}

View file

@ -0,0 +1,105 @@
import { ref, onUnmounted } from 'vue'
export interface SSEEvent {
type: string
data: unknown
}
export function useSSE(url: string) {
const events = ref<SSEEvent[]>([])
const connected = ref(false)
const error = ref<string | null>(null)
let es: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let closed = false
function connect() {
if (closed) return
try {
es = new EventSource(url)
es.onopen = () => {
connected.value = true
error.value = null
}
es.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data)
events.value.push({ type: 'message', data: parsed })
// Keep last 200 events
if (events.value.length > 200) events.value.shift()
} catch {
events.value.push({ type: 'message', data: e.data })
}
}
es.addEventListener('session_start', (e: MessageEvent) => {
try {
events.value.push({ type: 'session_start', data: JSON.parse(e.data) })
} catch {
events.value.push({ type: 'session_start', data: e.data })
}
if (events.value.length > 200) events.value.shift()
})
es.addEventListener('session_end', (e: MessageEvent) => {
try {
events.value.push({ type: 'session_end', data: JSON.parse(e.data) })
} catch {
events.value.push({ type: 'session_end', data: e.data })
}
if (events.value.length > 200) events.value.shift()
})
es.addEventListener('activity', (e: MessageEvent) => {
try {
events.value.push({ type: 'activity', data: JSON.parse(e.data) })
} catch {
events.value.push({ type: 'activity', data: e.data })
}
if (events.value.length > 200) events.value.shift()
})
es.onerror = () => {
connected.value = false
error.value = 'Connection lost, reconnecting...'
es?.close()
es = null
if (!closed) {
reconnectTimer = setTimeout(() => connect(), 5000)
}
}
} catch (err) {
error.value = 'Failed to connect to event stream'
if (!closed) {
reconnectTimer = setTimeout(() => connect(), 5000)
}
}
}
function disconnect() {
closed = true
if (reconnectTimer) clearTimeout(reconnectTimer)
es?.close()
es = null
connected.value = false
}
function clearEvents() {
events.value = []
}
onUnmounted(() => {
disconnect()
})
return {
events,
connected,
error,
connect,
disconnect,
clearEvents,
}
}

93
web/src/lib/calendar.ts Normal file
View file

@ -0,0 +1,93 @@
import type { CalendarBlock } from '@/types'
export const PX_PER_MIN = 40 / 30 // 40px per 30-min slot
export interface BlockWithLane {
block: CalendarBlock
lane: number
totalLanes: number
}
export function packLanes(blocks: CalendarBlock[]): BlockWithLane[] {
if (blocks.length === 0) return []
// Sort by start time
const sorted = [...blocks].sort(
(a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
)
// lanes[i] = end time of last block in lane i
const lanes: number[] = []
const assignments: { block: CalendarBlock; lane: number }[] = []
for (const block of sorted) {
const start = new Date(block.start_at).getTime()
const end = new Date(block.end_at).getTime()
// Find first lane where the last block ends before this starts
let assignedLane = -1
for (let i = 0; i < lanes.length; i++) {
if (lanes[i] <= start) {
assignedLane = i
break
}
}
if (assignedLane === -1) {
// Need a new lane
assignedLane = lanes.length
lanes.push(end)
} else {
lanes[assignedLane] = end
}
assignments.push({ block, lane: assignedLane })
}
// Now we need to compute totalLanes for each block based on its overlapping group
const result: BlockWithLane[] = assignments.map(({ block, lane }) => {
const blockStart = new Date(block.start_at).getTime()
const blockEnd = new Date(block.end_at).getTime()
// Count how many blocks overlap with this one
let maxLane = lane
for (const other of assignments) {
const otherStart = new Date(other.block.start_at).getTime()
const otherEnd = new Date(other.block.end_at).getTime()
if (otherStart < blockEnd && otherEnd > blockStart) {
if (other.lane > maxLane) maxLane = other.lane
}
}
return { block, lane, totalLanes: maxLane + 1 }
})
return result
}
export function blockTopPx(startAt: Date, dayStart: number = 7): number {
const minutesFromDayStart = (startAt.getHours() - dayStart) * 60 + startAt.getMinutes()
return minutesFromDayStart * PX_PER_MIN
}
export function blockHeightPx(startAt: Date, endAt: Date): number {
const durationMinutes = (endAt.getTime() - startAt.getTime()) / 60000
return Math.max(durationMinutes * PX_PER_MIN, 20)
}
export function getWeekDays(date: Date): Date[] {
const day = date.getDay() // 0 = Sunday
const monday = new Date(date)
monday.setDate(date.getDate() - ((day + 6) % 7))
monday.setHours(0, 0, 0, 0)
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
return d
})
}
export function snapToGrid(minutes: number, snap = 15): number {
return Math.round(minutes / snap) * snap
}

22
web/src/lib/color.ts Normal file
View file

@ -0,0 +1,22 @@
export function hslFromHue(hue: number, alpha = 1): string {
return `hsla(${hue}, 65%, 55%, ${alpha})`
}
export function hslBgFromHue(hue: number): string {
return `hsla(${hue}, 65%, 45%, 0.85)`
}
export function hslBorderFromHue(hue: number): string {
return `hsla(${hue}, 65%, 55%, 1)`
}
export function contrastColor(_hue: number): string {
return 'white'
}
export function tagStyle(colorHex: string): { background: string; color: string } {
return {
background: `${colorHex}33`,
color: colorHex,
}
}

45
web/src/lib/utils.ts Normal file
View file

@ -0,0 +1,45 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(hours: number): string {
const h = Math.floor(hours)
const m = Math.round((hours - h) * 60)
if (h === 0) return `${m}m`
if (m === 0) return `${h}h`
return `${h}h ${m}m`
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
}
export function isoDateStr(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

27
web/src/main.ts Normal file
View file

@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
import router from './router'
import { setupInterceptors } from './api/client'
import { useAuthStore } from './stores/auth'
import './styles/globals.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(VueQueryPlugin)
// Setup axios interceptors after pinia is installed
const authStore = useAuthStore()
setupInterceptors(
() => authStore.getToken(),
() => {
authStore.logout()
router.push({ name: 'login' })
}
)
app.mount('#app')

100
web/src/router/index.ts Normal file
View file

@ -0,0 +1,100 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/components/shared/AppLayout.vue'),
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: 'calendar',
name: 'calendar',
component: () => import('@/views/CalendarView.vue'),
},
{
path: 'planner',
name: 'planner',
component: () => import('@/views/PlannerView.vue'),
},
{
path: 'projects',
name: 'projects',
component: () => import('@/views/ProjectsView.vue'),
},
{
path: 'projects/:id',
name: 'project-detail',
component: () => import('@/views/ProjectDetailView.vue'),
},
{
path: 'live',
name: 'live',
component: () => import('@/views/LiveView.vue'),
},
{
path: 'reports',
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
},
{
path: 'keys',
name: 'keys',
component: () => import('@/views/KeysView.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
{
path: 'admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
meta: { adminOnly: true },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory('/cc-dashboard/'),
routes,
})
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.public) {
next()
return
}
if (!authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
if (to.meta.adminOnly && !authStore.isAdmin) {
next({ name: 'dashboard' })
return
}
next()
})
export default router

72
web/src/stores/auth.ts Normal file
View file

@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '@/api/client'
import type { UserOut } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// Token stored in memory only - lost on page refresh (by design)
const token = ref<string | null>(null)
const user = ref<UserOut | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(email: string, password: string): Promise<void> {
loading.value = true
error.value = null
try {
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const res = await apiClient.post<{ access_token: string; token_type: string }>(
'/api/auth/login',
formData,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
)
token.value = res.data.access_token
await fetchMe()
} catch (err: unknown) {
const axiosError = err as { response?: { data?: { detail?: string } } }
error.value = axiosError.response?.data?.detail ?? 'Login failed'
throw err
} finally {
loading.value = false
}
}
async function logout(): Promise<void> {
try {
await apiClient.post('/api/auth/logout')
} catch {
// ignore
} finally {
token.value = null
user.value = null
}
}
async function fetchMe(): Promise<void> {
const res = await apiClient.get<UserOut>('/api/auth/me')
user.value = res.data
}
function getToken(): string | null {
return token.value
}
return {
token,
user,
loading,
error,
isAuthenticated,
isAdmin,
login,
logout,
fetchMe,
getToken,
}
})

110
web/src/stores/calendar.ts Normal file
View file

@ -0,0 +1,110 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { dashboardApi } from '@/api/endpoints/dashboard'
import { getWeekDays } from '@/lib/calendar'
import { isoDateStr } from '@/lib/utils'
import type { CalendarBlock } from '@/types'
export const useCalendarStore = defineStore('calendar', () => {
const blocks = ref<CalendarBlock[]>([])
const currentDate = ref<Date>(new Date())
const view = ref<'week' | 'day'>('week')
const loading = ref(false)
const error = ref<string | null>(null)
const weekDays = computed(() => getWeekDays(currentDate.value))
async function fetch(from: string, to: string, viewMode: 'week' | 'day'): Promise<void> {
loading.value = true
error.value = null
try {
const res = await dashboardApi.calendar({ from, to, view: viewMode })
blocks.value = res.data
} catch (err: unknown) {
const e = err as { message?: string }
error.value = e.message ?? 'Failed to fetch calendar'
} finally {
loading.value = false
}
}
async function fetchCurrentView(): Promise<void> {
if (view.value === 'week') {
const days = getWeekDays(currentDate.value)
const from = isoDateStr(days[0])
const to = isoDateStr(days[6])
await fetch(from, to, 'week')
} else {
const dateStr = isoDateStr(currentDate.value)
await fetch(dateStr, dateStr, 'day')
}
}
function navigatePrev(): void {
const d = new Date(currentDate.value)
if (view.value === 'week') {
d.setDate(d.getDate() - 7)
} else {
d.setDate(d.getDate() - 1)
}
currentDate.value = d
}
function navigateNext(): void {
const d = new Date(currentDate.value)
if (view.value === 'week') {
d.setDate(d.getDate() + 7)
} else {
d.setDate(d.getDate() + 1)
}
currentDate.value = d
}
function goToToday(): void {
currentDate.value = new Date()
}
function setView(v: 'week' | 'day'): void {
view.value = v
}
function addBlock(block: CalendarBlock): void {
blocks.value.push(block)
}
function updateBlock(updated: CalendarBlock): void {
const idx = blocks.value.findIndex((b) => b.id === updated.id)
if (idx !== -1) blocks.value[idx] = updated
}
function removeBlock(id: string): void {
blocks.value = blocks.value.filter((b) => b.id !== id)
}
function getBlocksForDay(date: Date): CalendarBlock[] {
const dateStr = isoDateStr(date)
return blocks.value.filter((b) => {
const blockDate = isoDateStr(new Date(b.start_at))
return blockDate === dateStr
})
}
return {
blocks,
currentDate,
view,
loading,
error,
weekDays,
fetch,
fetchCurrentView,
navigatePrev,
navigateNext,
goToToday,
setView,
addBlock,
updateBlock,
removeBlock,
getBlocksForDay,
}
})

75
web/src/stores/devops.ts Normal file
View file

@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { devopsApi } from '@/api/endpoints/devops'
import type { AzureIntegration, AzureWorkItem } from '@/types'
import type { IntegrationPayload } from '@/api/endpoints/devops'
export const useDevopsStore = defineStore('devops', () => {
const integration = ref<AzureIntegration | null>(null)
const workItems = ref<AzureWorkItem[]>([])
const syncing = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchIntegration(): Promise<void> {
loading.value = true
try {
const res = await devopsApi.getIntegration()
integration.value = res.data
} catch {
integration.value = null
} finally {
loading.value = false
}
}
async function saveIntegration(payload: IntegrationPayload): Promise<void> {
const res = await devopsApi.saveIntegration(payload)
integration.value = res.data
}
async function deleteIntegration(): Promise<void> {
await devopsApi.deleteIntegration()
integration.value = null
}
async function sync(): Promise<void> {
syncing.value = true
error.value = null
try {
await devopsApi.sync()
await fetchIntegration()
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } }; message?: string }
error.value = e.response?.data?.detail ?? e.message ?? 'Sync failed'
throw err
} finally {
syncing.value = false
}
}
async function fetchWorkItems(state?: string): Promise<void> {
loading.value = true
try {
const res = await devopsApi.workItems(state)
workItems.value = res.data
} catch {
workItems.value = []
} finally {
loading.value = false
}
}
return {
integration,
workItems,
syncing,
loading,
error,
fetchIntegration,
saveIntegration,
deleteIntegration,
sync,
fetchWorkItems,
}
})

93
web/src/stores/tasks.ts Normal file
View file

@ -0,0 +1,93 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tasksApi } from '@/api/endpoints/tasks'
import type { Task, TaskBlock } from '@/types'
import type { TaskCreatePayload, TaskUpdatePayload, BlockCreatePayload, BlockUpdatePayload } from '@/api/endpoints/tasks'
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchForDate(date: string): Promise<void> {
loading.value = true
error.value = null
try {
const res = await tasksApi.list({ date })
tasks.value = res.data
} catch (err: unknown) {
const e = err as { message?: string }
error.value = e.message ?? 'Failed to fetch tasks'
} finally {
loading.value = false
}
}
async function fetchAll(projectId?: string): Promise<void> {
loading.value = true
error.value = null
try {
const res = await tasksApi.list(projectId ? { project_id: projectId } : undefined)
tasks.value = res.data
} catch (err: unknown) {
const e = err as { message?: string }
error.value = e.message ?? 'Failed to fetch tasks'
} finally {
loading.value = false
}
}
async function create(payload: TaskCreatePayload): Promise<Task> {
const res = await tasksApi.create(payload)
tasks.value.push(res.data)
return res.data
}
async function update(id: string, payload: TaskUpdatePayload): Promise<Task> {
const res = await tasksApi.update(id, payload)
const idx = tasks.value.findIndex((t) => t.id === id)
if (idx !== -1) tasks.value[idx] = res.data
return res.data
}
async function remove(id: string): Promise<void> {
await tasksApi.remove(id)
tasks.value = tasks.value.filter((t) => t.id !== id)
}
async function complete(id: string): Promise<Task> {
const res = await tasksApi.complete(id)
const idx = tasks.value.findIndex((t) => t.id === id)
if (idx !== -1) tasks.value[idx] = res.data
return res.data
}
async function createBlock(taskId: string, payload: BlockCreatePayload): Promise<TaskBlock> {
const res = await tasksApi.createBlock(taskId, payload)
return res.data
}
async function updateBlock(id: string, payload: BlockUpdatePayload): Promise<TaskBlock> {
const res = await tasksApi.updateBlock(id, payload)
return res.data
}
async function deleteBlock(id: string): Promise<void> {
await tasksApi.deleteBlock(id)
}
return {
tasks,
loading,
error,
fetchForDate,
fetchAll,
create,
update,
remove,
complete,
createBlock,
updateBlock,
deleteBlock,
}
})

View file

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 215 27.9% 10%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground;
}
}

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import { packLanes, blockTopPx, blockHeightPx } from '@/lib/calendar'
describe('packLanes', () => {
it('assigns lane 0 to non-overlapping blocks', () => {
const b1 = { start_at: '2026-05-06T09:00:00Z', end_at: '2026-05-06T10:00:00Z' }
const b2 = { start_at: '2026-05-06T11:00:00Z', end_at: '2026-05-06T12:00:00Z' }
const result = packLanes([b1 as any, b2 as any])
expect(result[0].lane).toBe(0)
expect(result[1].lane).toBe(0)
expect(result[0].totalLanes).toBe(1)
})
it('assigns different lanes to overlapping blocks', () => {
const b1 = { start_at: '2026-05-06T09:00:00Z', end_at: '2026-05-06T11:00:00Z' }
const b2 = { start_at: '2026-05-06T10:00:00Z', end_at: '2026-05-06T12:00:00Z' }
const result = packLanes([b1 as any, b2 as any])
expect(result[0].lane).toBe(0)
expect(result[1].lane).toBe(1)
expect(result[0].totalLanes).toBe(2)
expect(result[1].totalLanes).toBe(2)
})
it('handles empty array', () => {
const result = packLanes([])
expect(result).toEqual([])
})
it('handles single block', () => {
const b = { start_at: '2026-05-06T09:00:00Z', end_at: '2026-05-06T10:00:00Z' }
const result = packLanes([b as any])
expect(result[0].lane).toBe(0)
expect(result[0].totalLanes).toBe(1)
})
it('packs 3 concurrent blocks into separate lanes', () => {
const b1 = { start_at: '2026-05-06T09:00:00Z', end_at: '2026-05-06T12:00:00Z' }
const b2 = { start_at: '2026-05-06T09:30:00Z', end_at: '2026-05-06T11:30:00Z' }
const b3 = { start_at: '2026-05-06T10:00:00Z', end_at: '2026-05-06T11:00:00Z' }
const result = packLanes([b1 as any, b2 as any, b3 as any])
const lanes = result.map((r) => r.lane)
expect(new Set(lanes).size).toBe(3)
})
})
describe('blockTopPx', () => {
it('returns 0 for 7:00 AM', () => {
const d = new Date('2026-05-06T07:00:00')
expect(blockTopPx(d)).toBe(0)
})
it('returns 80 for 8:00 AM (60 min * 40/30)', () => {
const d = new Date('2026-05-06T08:00:00')
expect(blockTopPx(d)).toBeCloseTo(80)
})
it('returns 40 for 7:30 AM (30 min * 40/30)', () => {
const d = new Date('2026-05-06T07:30:00')
expect(blockTopPx(d)).toBeCloseTo(40)
})
})
describe('blockHeightPx', () => {
it('returns correct height for 1 hour', () => {
const start = new Date('2026-05-06T09:00:00')
const end = new Date('2026-05-06T10:00:00')
expect(blockHeightPx(start, end)).toBeCloseTo(80)
})
it('returns minimum height of 20px for very short blocks', () => {
const start = new Date('2026-05-06T09:00:00')
const end = new Date('2026-05-06T09:01:00')
expect(blockHeightPx(start, end)).toBe(20)
})
it('returns correct height for 30 minutes', () => {
const start = new Date('2026-05-06T09:00:00')
const end = new Date('2026-05-06T09:30:00')
expect(blockHeightPx(start, end)).toBeCloseTo(40)
})
})

192
web/src/types/index.ts Normal file
View file

@ -0,0 +1,192 @@
export interface TagBrief {
id: string
name: string
color_hex: string
}
export interface CalendarBlock {
kind: 'session' | 'planned' | 'manual'
id: string
project_id: string | null
job_number: string
display_name: string
start_at: string
end_at: string
title: string
color_hue: number
tags: TagBrief[]
task_id: string | null
session_id: string | null
manual_entry_id: string | null
}
export interface Task {
id: string
title: string
notes: string
planned_date: string
estimate_hours: number
actual_hours: number
status: 'todo' | 'doing' | 'done' | 'cancelled'
priority: 1 | 2 | 3 | 4 | 5
sort_index: number
project_id: string | null
azure_work_item_id: string | null
completed_at: string | null
tags: TagBrief[]
}
export interface TaskBlock {
id: string
task_id: string
start_at: string
end_at: string
created_at: string
}
export interface KpiSummary {
total_hours: number
total_projects: number
working_days: number
total_sessions: number
avg_hours_per_day: number
top_project: string
total_commits: number
total_files_changed: number
period_from: string
period_to: string
}
export interface ProjectSummary {
project_id: string
display_name: string
client: string
job_number: string
total_hours: number
session_count: number
last_active: string
budget_hours: number | null
progress_pct: number | null
}
export interface MonthlyDataPoint {
date: string
hours: number
}
export interface DowDataPoint {
dow: number
label: string
hours: number
}
export interface ToolUsage {
tool: string
hours: number
pct: number
}
export interface ActivityEvent {
id: string
session_id: string
project_id: string
display_name: string
message: string
tool: string | null
timestamp: string
}
export interface TimelinePoint {
date: string
hours: number
commits: number
}
export interface ProjectDetail {
project_id: string
display_name: string
client: string
job_number: string
repo_url: string | null
total_hours: number
timeline: TimelinePoint[]
top_files: { path: string; count: number }[]
top_tools: ToolUsage[]
sessions: SessionBrief[]
}
export interface SessionBrief {
id: string
start_at: string
end_at: string
duration_hours: number
summary: string | null
commit_count: number
tool_count: number
}
export interface UserOut {
id: string
email: string
username: string
role: 'admin' | 'user'
is_active: boolean
daily_overhead_hours: number
created_at: string
}
export interface AiReport {
id: string
type: 'daily' | 'weekly'
period_date: string
content_markdown: string
email_sent: boolean
generated_at: string
}
export interface ApiKey {
id: string
prefix: string
label: string
created_at: string
last_used: string | null
}
export interface ManualEntry {
id: string
project_id: string | null
display_name: string
start_at: string
end_at: string
notes: string
tags: TagBrief[]
}
export interface Budget {
id: string
project_id: string
display_name: string
budget_hours: number
used_hours: number
progress_pct: number
period_start: string
period_end: string
}
export interface AzureIntegration {
id: string
org: string
project: string
pat_hint: string
last_synced_at: string | null
last_sync_error: string | null
}
export interface AzureWorkItem {
id: number
title: string
state: string
type: string
assigned_to: string | null
url: string
}

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { adminApi } from '@/api/endpoints/admin'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Badge from '@/components/ui/Badge.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { formatDate } from '@/lib/utils'
import type { UserOut } from '@/types'
const authStore = useAuthStore()
const router = useRouter()
const users = ref<UserOut[]>([])
const loading = ref(false)
onMounted(async () => {
if (!authStore.isAdmin) {
router.push('/')
return
}
loading.value = true
try {
const res = await adminApi.users()
users.value = res.data
} finally {
loading.value = false
}
})
</script>
<template>
<div class="p-6">
<h2 class="text-lg font-semibold text-foreground mb-6">Admin Users</h2>
<div v-if="loading" class="flex items-center justify-center h-20">
<Spinner class="text-primary" />
</div>
<Card v-else>
<CardContent class="p-0">
<table class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">User</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Email</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Role</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Status</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Joined</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-b border-border last:border-0 hover:bg-muted/30"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-foreground">{{ user.username }}</p>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">{{ user.email }}</td>
<td class="px-4 py-3">
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs">
{{ user.role }}
</Badge>
</td>
<td class="px-4 py-3">
<Badge :variant="user.is_active ? 'success' : 'outline'" class="text-xs">
{{ user.is_active ? 'Active' : 'Inactive' }}
</Badge>
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{{ formatDate(user.created_at) }}</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
</div>
</template>

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useCalendarStore } from '@/stores/calendar'
import CalendarToolbar from '@/components/calendar/CalendarToolbar.vue'
import CalendarGrid from '@/components/calendar/CalendarGrid.vue'
import PlannerSidebar from '@/components/calendar/PlannerSidebar.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import { useTasksStore } from '@/stores/tasks'
import { isoDateStr } from '@/lib/utils'
import { toast } from 'vue-sonner'
import type { CalendarBlock } from '@/types'
const calendarStore = useCalendarStore()
const tasksStore = useTasksStore()
const showPlanner = ref(true)
const showTaskForm = ref(false)
const selectedBlock = ref<CalendarBlock | null>(null)
onMounted(() => {
calendarStore.fetchCurrentView()
})
function handleBlockClick(block: CalendarBlock) {
selectedBlock.value = block
}
async function handleCreateTask(payload: Parameters<typeof tasksStore.create>[0]) {
try {
await tasksStore.create(payload)
toast.success('Task created')
showTaskForm.value = false
tasksStore.fetchForDate(isoDateStr(calendarStore.currentDate))
} catch {
toast.error('Failed to create task')
}
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- Toolbar -->
<div class="p-4 border-b border-border flex items-center gap-3 flex-wrap">
<CalendarToolbar />
<div class="flex items-center gap-2 ml-auto">
<button
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="showPlanner = !showPlanner"
>
{{ showPlanner ? 'Hide Planner' : 'Show Planner' }}
</button>
</div>
</div>
<!-- Main area -->
<div class="flex-1 flex overflow-hidden">
<!-- Calendar grid -->
<div class="flex-1 overflow-auto">
<CalendarGrid @block-click="handleBlockClick" />
</div>
<!-- Planner sidebar -->
<div v-if="showPlanner" class="w-56 shrink-0 overflow-hidden">
<PlannerSidebar @create-task="showTaskForm = true" />
</div>
</div>
<!-- Block detail popover -->
<div
v-if="selectedBlock"
class="fixed inset-0 z-40 flex items-center justify-center p-4"
@click.self="selectedBlock = null"
>
<div class="bg-card border border-border rounded-lg shadow-xl p-4 w-72">
<div class="flex items-start justify-between gap-2 mb-3">
<div>
<p class="font-semibold text-sm text-foreground">{{ selectedBlock.display_name }}</p>
<p v-if="selectedBlock.job_number" class="text-xs text-muted-foreground">
{{ selectedBlock.job_number }}
</p>
</div>
<button
class="text-muted-foreground hover:text-foreground"
@click="selectedBlock = null"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<p>Start: {{ new Date(selectedBlock.start_at).toLocaleString() }}</p>
<p>End: {{ new Date(selectedBlock.end_at).toLocaleString() }}</p>
<p>Type: {{ selectedBlock.kind }}</p>
</div>
<div v-if="selectedBlock.tags.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="tag in selectedBlock.tags"
:key="tag.id"
class="px-1.5 py-0.5 rounded text-xs"
:style="{ background: `${tag.color_hex}22`, color: tag.color_hex }"
>
{{ tag.name }}
</span>
</div>
</div>
</div>
<!-- Task form -->
<TaskForm
:open="showTaskForm"
:default-date="isoDateStr(calendarStore.currentDate)"
@close="showTaskForm = false"
@save="handleCreateTask"
/>
</div>
</template>

View file

@ -0,0 +1,288 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { dashboardApi } from '@/api/endpoints/dashboard'
import KpiCard from '@/components/dashboard/KpiCard.vue'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Progress from '@/components/ui/Progress.vue'
import Button from '@/components/ui/Button.vue'
import { formatDuration, formatDate, isoDateStr } from '@/lib/utils'
import type { KpiSummary, ProjectSummary, MonthlyDataPoint, DowDataPoint, ToolUsage } from '@/types'
type Preset = 'today' | '7d' | '30d' | 'custom'
const preset = ref<Preset>('30d')
const customFrom = ref('')
const customTo = ref('')
const summary = ref<KpiSummary | null>(null)
const projects = ref<ProjectSummary[]>([])
const monthly = ref<MonthlyDataPoint[]>([])
const dow = ref<DowDataPoint[]>([])
const tools = ref<ToolUsage[]>([])
const loading = ref(false)
const dateRange = computed(() => {
const now = new Date()
const todayStr = isoDateStr(now)
if (preset.value === 'today') {
return { from: todayStr, to: todayStr }
} else if (preset.value === '7d') {
const past = new Date(now)
past.setDate(now.getDate() - 7)
return { from: isoDateStr(past), to: todayStr }
} else if (preset.value === '30d') {
const past = new Date(now)
past.setDate(now.getDate() - 30)
return { from: isoDateStr(past), to: todayStr }
} else {
return { from: customFrom.value || todayStr, to: customTo.value || todayStr }
}
})
async function loadData() {
if (preset.value === 'custom' && (!customFrom.value || !customTo.value)) return
loading.value = true
try {
const params = dateRange.value
const [s, p, m, d, t] = await Promise.all([
dashboardApi.summary(params),
dashboardApi.projects(params),
dashboardApi.monthly(params),
dashboardApi.dow(params),
dashboardApi.tools(params),
])
summary.value = s.data
projects.value = p.data
monthly.value = m.data
dow.value = d.data
tools.value = t.data
} catch (e) {
console.error('Failed to load dashboard data', e)
} finally {
loading.value = false
}
}
watch(preset, () => {
if (preset.value !== 'custom') loadData()
})
onMounted(() => loadData())
// Chart helpers
const maxMonthlyHours = computed(() => Math.max(...monthly.value.map((d) => d.hours), 1))
const maxDowHours = computed(() => Math.max(...dow.value.map((d) => d.hours), 1))
const maxToolPct = computed(() => Math.max(...tools.value.map((t) => t.pct), 1))
const progressColor = (pct: number | null) => {
if (!pct) return 'default'
if (pct > 90) return 'danger'
if (pct > 70) return 'warning'
return 'success'
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header + Date filter -->
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-lg font-semibold text-foreground flex-1">Overview</h2>
<!-- Preset buttons -->
<div class="flex items-center rounded-md border border-border overflow-hidden">
<button
v-for="p in ['today', '7d', '30d', 'custom']"
:key="p"
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
preset === p
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
@click="preset = p as Preset"
>
{{ p === 'today' ? 'Today' : p === '7d' ? '7 days' : p === '30d' ? '30 days' : 'Custom' }}
</button>
</div>
<!-- Custom date range -->
<template v-if="preset === 'custom'">
<input
v-model="customFrom"
type="date"
class="h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-xs text-muted-foreground">to</span>
<input
v-model="customTo"
type="date"
class="h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<Button size="sm" :loading="loading" @click="loadData">Apply</Button>
</template>
</div>
<!-- KPI cards -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<KpiCard
label="Total Hours"
:value="summary ? formatDuration(summary.total_hours) : '-'"
icon="clock"
:loading="loading"
/>
<KpiCard
label="Working Days"
:value="summary?.working_days ?? '-'"
icon="calendar"
:loading="loading"
/>
<KpiCard
label="Projects"
:value="summary?.total_projects ?? '-'"
icon="folder"
:loading="loading"
/>
<KpiCard
label="Avg / Day"
:value="summary ? formatDuration(summary.avg_hours_per_day) : '-'"
icon="trending-up"
:loading="loading"
/>
<KpiCard
label="Top Project"
:value="summary?.top_project ?? '-'"
icon="star"
:loading="loading"
/>
<KpiCard
label="Commits"
:value="summary?.total_commits ?? '-'"
icon="git"
:loading="loading"
/>
</div>
<!-- Charts row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Monthly bar chart -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Hours by Day</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="h-40 flex items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
<div v-else-if="monthly.length === 0" class="h-40 flex items-center justify-center text-sm text-muted-foreground">
No data
</div>
<div v-else class="h-40 flex items-end gap-px">
<div
v-for="point in monthly"
:key="point.date"
class="flex-1 flex flex-col items-center gap-0.5 group"
:title="`${point.date}: ${formatDuration(point.hours)}`"
>
<div
class="w-full bg-primary/70 hover:bg-primary rounded-t transition-colors"
:style="{ height: `${(point.hours / maxMonthlyHours) * 100}%` }"
/>
</div>
</div>
</CardContent>
</Card>
<!-- Day of week bar chart -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Hours by Day of Week</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="h-40 flex items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
<div v-else-if="dow.length === 0" class="h-40 flex items-center justify-center text-sm text-muted-foreground">
No data
</div>
<div v-else class="h-40 flex items-end gap-2">
<div
v-for="point in dow"
:key="point.dow"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="w-full bg-primary/70 hover:bg-primary rounded-t transition-colors"
:style="{ height: `${Math.max((point.hours / maxDowHours) * 100, 2)}%` }"
:title="`${formatDuration(point.hours)}`"
/>
<span class="text-xs text-muted-foreground">{{ point.label.slice(0, 2) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Bottom row: Tools + Projects -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Tool usage -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Tool Usage</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="space-y-2">
<div v-for="i in 5" :key="i" class="h-6 bg-muted animate-pulse rounded" />
</div>
<div v-else-if="tools.length === 0" class="text-sm text-muted-foreground py-4 text-center">
No data
</div>
<div v-else class="space-y-2">
<div v-for="tool in tools.slice(0, 8)" :key="tool.tool" class="flex items-center gap-2">
<span class="text-xs text-foreground w-24 truncate shrink-0">{{ tool.tool }}</span>
<div class="flex-1 h-2 bg-secondary rounded-full overflow-hidden">
<div
class="h-full bg-primary rounded-full"
:style="{ width: `${(tool.pct / maxToolPct) * 100}%` }"
/>
</div>
<span class="text-xs text-muted-foreground w-10 text-right shrink-0">
{{ tool.pct.toFixed(0) }}%
</span>
</div>
</div>
</CardContent>
</Card>
<!-- Project hours table -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Projects</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="space-y-2">
<div v-for="i in 5" :key="i" class="h-8 bg-muted animate-pulse rounded" />
</div>
<div v-else-if="projects.length === 0" class="text-sm text-muted-foreground py-4 text-center">
No data
</div>
<div v-else class="space-y-2">
<div v-for="proj in projects.slice(0, 8)" :key="proj.project_id">
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="text-foreground truncate max-w-[160px]">{{ proj.display_name }}</span>
<span class="text-muted-foreground shrink-0">{{ formatDuration(proj.total_hours) }}</span>
</div>
<Progress
v-if="proj.progress_pct !== null"
:value="proj.progress_pct"
:color="progressColor(proj.progress_pct)"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

141
web/src/views/KeysView.vue Normal file
View file

@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { adminApi } from '@/api/endpoints/admin'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Button from '@/components/ui/Button.vue'
import Dialog from '@/components/ui/Dialog.vue'
import Input from '@/components/ui/Input.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { toast } from 'vue-sonner'
import { formatDate } from '@/lib/utils'
import type { ApiKey } from '@/types'
const keys = ref<ApiKey[]>([])
const loading = ref(false)
const showCreate = ref(false)
const newKeyLabel = ref('')
const creating = ref(false)
const generatedKey = ref<string | null>(null)
onMounted(() => loadKeys())
async function loadKeys() {
loading.value = true
try {
const res = await adminApi.keys()
keys.value = res.data
} finally {
loading.value = false
}
}
async function handleCreate() {
if (!newKeyLabel.value.trim()) return
creating.value = true
try {
const res = await adminApi.createKey({ label: newKeyLabel.value })
generatedKey.value = res.data.key
toast.success('API key created')
await loadKeys()
newKeyLabel.value = ''
} catch {
toast.error('Failed to create key')
} finally {
creating.value = false
}
}
async function handleRevoke(key: ApiKey) {
if (!confirm(`Revoke key "${key.label}"? This cannot be undone.`)) return
try {
await adminApi.revokeKey(key.id)
toast.success('Key revoked')
keys.value = keys.value.filter((k) => k.id !== key.id)
} catch {
toast.error('Failed to revoke key')
}
}
</script>
<template>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-foreground">API Keys</h2>
<Button size="sm" @click="showCreate = true; generatedKey = null">
<svg class="h-4 w-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Key
</Button>
</div>
<Card>
<CardContent class="p-0">
<div v-if="loading" class="flex items-center justify-center h-20">
<Spinner class="text-primary" />
</div>
<div v-else-if="keys.length === 0" class="text-center text-muted-foreground py-8 text-sm">
No API keys
</div>
<table v-else class="w-full">
<thead>
<tr class="border-b border-border">
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Label</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Prefix</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Created</th>
<th class="text-left text-xs font-medium text-muted-foreground px-4 py-3">Last Used</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody>
<tr
v-for="key in keys"
:key="key.id"
class="border-b border-border last:border-0 hover:bg-muted/30"
>
<td class="px-4 py-3 text-sm text-foreground">{{ key.label }}</td>
<td class="px-4 py-3 text-sm font-mono text-muted-foreground">{{ key.prefix }}...</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{{ formatDate(key.created_at) }}</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{{ key.last_used ? formatDate(key.last_used) : 'Never' }}
</td>
<td class="px-4 py-3 text-right">
<Button variant="ghost" size="sm" class="text-destructive" @click="handleRevoke(key)">
Revoke
</Button>
</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
<!-- Create dialog -->
<Dialog :open="showCreate" title="Create API Key" @close="showCreate = false">
<div class="space-y-4">
<!-- Generated key display -->
<div v-if="generatedKey" class="rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3">
<p class="text-xs text-emerald-400 font-medium mb-1">Key created save it now!</p>
<p class="text-xs font-mono text-foreground break-all">{{ generatedKey }}</p>
</div>
<div v-else class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Label</label>
<Input v-model="newKeyLabel" placeholder="e.g. claude-collector" :disabled="creating" />
</div>
</div>
<template #footer>
<Button variant="outline" @click="showCreate = false">
{{ generatedKey ? 'Done' : 'Cancel' }}
</Button>
<Button v-if="!generatedKey" :loading="creating" @click="handleCreate">
Create
</Button>
</template>
</Dialog>
</div>
</template>

122
web/src/views/LiveView.vue Normal file
View file

@ -0,0 +1,122 @@
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useSSE } from '@/composables/useSSE'
import { useAuthStore } from '@/stores/auth'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Button from '@/components/ui/Button.vue'
import { formatDateTime } from '@/lib/utils'
const authStore = useAuthStore()
const { events, connected, error, connect, clearEvents } = useSSE('/cc-dashboard/api/events/stream')
onMounted(() => {
if (authStore.isAuthenticated) {
connect()
}
})
const recentEvents = computed(() => [...events.value].reverse().slice(0, 100))
function getEventColor(type: string): string {
if (type === 'session_start') return 'text-emerald-400'
if (type === 'session_end') return 'text-amber-400'
if (type === 'activity') return 'text-blue-400'
return 'text-muted-foreground'
}
function getEventIcon(type: string): string {
if (type === 'session_start') return '▶'
if (type === 'session_end') return '■'
if (type === 'activity') return '●'
return '○'
}
function formatEventData(data: unknown): string {
if (typeof data === 'string') return data
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
return obj.message as string || obj.summary as string || JSON.stringify(data)
}
return String(data)
}
function getProjectName(data: unknown): string {
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
return obj.display_name as string || obj.project_id as string || ''
}
return ''
}
</script>
<template>
<div class="p-6 h-full flex flex-col">
<!-- Header -->
<div class="flex items-center gap-3 mb-4">
<h2 class="text-lg font-semibold text-foreground flex-1">Live Feed</h2>
<!-- Connection status -->
<div class="flex items-center gap-2">
<div
:class="[
'h-2 w-2 rounded-full',
connected ? 'bg-emerald-500 animate-pulse' : 'bg-red-500',
]"
/>
<span class="text-xs text-muted-foreground">
{{ connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<Button v-if="!connected" variant="outline" size="sm" @click="connect">
Reconnect
</Button>
<Button variant="ghost" size="sm" @click="clearEvents">
Clear
</Button>
</div>
<!-- Error -->
<div v-if="error && !connected" class="mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2">
{{ error }}
</div>
<!-- Event feed -->
<Card class="flex-1 overflow-hidden">
<CardContent class="p-0 h-full">
<div v-if="recentEvents.length === 0" class="flex items-center justify-center h-full text-sm text-muted-foreground">
<div class="text-center">
<div class="text-2xl mb-2">📡</div>
<p>Waiting for events...</p>
<p class="text-xs mt-1">Activity will appear here in real-time</p>
</div>
</div>
<div v-else class="overflow-y-auto h-full font-mono text-xs">
<div
v-for="(event, idx) in recentEvents"
:key="idx"
class="flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"
>
<span :class="getEventColor(event.type)" class="shrink-0 mt-0.5">
{{ getEventIcon(event.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span :class="getEventColor(event.type)" class="font-medium">
{{ event.type }}
</span>
<span v-if="getProjectName(event.data)" class="text-muted-foreground">
{{ getProjectName(event.data) }}
</span>
</div>
<p class="text-muted-foreground truncate mt-0.5">
{{ formatEventData(event.data) }}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
async function handleLogin() {
try {
await authStore.login(email.value, password.value)
const redirect = route.query.redirect as string | undefined
router.push(redirect ?? '/')
} catch {
// error is set in the store
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3">
<svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1>
<p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p>
</div>
<Card>
<CardContent class="pt-6">
<form class="space-y-4" @submit.prevent="handleLogin">
<!-- Error -->
<div
v-if="authStore.error"
class="rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"
>
{{ authStore.error }}
</div>
<div class="space-y-1.5">
<label for="email" class="text-sm font-medium text-foreground">Email</label>
<Input
id="email"
v-model="email"
type="email"
placeholder="you@company.com"
autocomplete="email"
:disabled="authStore.loading"
required
/>
</div>
<div class="space-y-1.5">
<label for="password" class="text-sm font-medium text-foreground">Password</label>
<Input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
autocomplete="current-password"
:disabled="authStore.loading"
required
/>
</div>
<Button
type="submit"
class="w-full"
:loading="authStore.loading"
>
Sign in
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</template>

View file

@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import TaskList from '@/components/tasks/TaskList.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import { toast } from 'vue-sonner'
import { isoDateStr } from '@/lib/utils'
import type { Task } from '@/types'
const tasksStore = useTasksStore()
const selectedDate = ref(isoDateStr(new Date()))
const showForm = ref(false)
const editingTask = ref<Task | null>(null)
const filterProject = ref('')
onMounted(() => {
tasksStore.fetchAll()
})
watch(selectedDate, () => {
tasksStore.fetchForDate(selectedDate.value)
})
const filteredTasks = computed(() => {
if (!filterProject.value) return tasksStore.tasks
return tasksStore.tasks.filter(
(t) =>
t.project_id?.toLowerCase().includes(filterProject.value.toLowerCase()) ||
t.title.toLowerCase().includes(filterProject.value.toLowerCase())
)
})
function openCreate() {
editingTask.value = null
showForm.value = true
}
function openEdit(task: Task) {
editingTask.value = task
showForm.value = true
}
async function handleSave(payload: Parameters<typeof tasksStore.create>[0]) {
try {
if (editingTask.value) {
await tasksStore.update(editingTask.value.id, payload)
toast.success('Task updated')
} else {
await tasksStore.create(payload)
toast.success('Task created')
}
showForm.value = false
tasksStore.fetchForDate(selectedDate.value)
} catch {
toast.error('Failed to save task')
}
}
async function handleComplete(task: Task) {
try {
await tasksStore.complete(task.id)
toast.success('Task completed')
} catch {
toast.error('Failed to complete task')
}
}
async function handleDelete(task: Task) {
if (!confirm(`Delete "${task.title}"?`)) return
try {
await tasksStore.remove(task.id)
toast.success('Task deleted')
} catch {
toast.error('Failed to delete task')
}
}
function navigateDate(dir: -1 | 1) {
const d = new Date(selectedDate.value)
d.setDate(d.getDate() + dir)
selectedDate.value = isoDateStr(d)
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-3 mb-6 flex-wrap">
<h2 class="text-lg font-semibold text-foreground flex-1">Planner</h2>
<!-- Date navigation -->
<div class="flex items-center gap-1">
<Button variant="outline" size="sm" @click="navigateDate(-1)">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Input
v-model="selectedDate"
type="date"
class="h-8 w-36 text-xs"
/>
<Button variant="outline" size="sm" @click="navigateDate(1)">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Button>
<Button
variant="outline"
size="sm"
@click="selectedDate = isoDateStr(new Date())"
>Today</Button>
</div>
<!-- Filter -->
<Input
v-model="filterProject"
placeholder="Search tasks..."
class="h-8 w-40 text-xs"
/>
<!-- New task -->
<Button size="sm" @click="openCreate">
<svg class="h-4 w-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Task
</Button>
</div>
<!-- Task list -->
<TaskList
:tasks="filteredTasks"
:loading="tasksStore.loading"
@edit="openEdit"
@complete="handleComplete"
@delete="handleDelete"
/>
<!-- Form dialog -->
<TaskForm
:open="showForm"
:task="editingTask"
:default-date="selectedDate"
@close="showForm = false"
@save="handleSave"
/>
</div>
</template>

View file

@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { dashboardApi } from '@/api/endpoints/dashboard'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { formatDuration, formatDateTime } from '@/lib/utils'
import type { ProjectDetail } from '@/types'
const route = useRoute()
const projectId = route.params.id as string
const detail = ref<ProjectDetail | null>(null)
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await dashboardApi.project(projectId)
detail.value = res.data
} finally {
loading.value = false
}
})
const maxTimelineHours = () =>
Math.max(...(detail.value?.timeline.map((p) => p.hours) ?? [1]), 1)
</script>
<template>
<div class="p-6">
<div v-if="loading" class="flex items-center justify-center h-40">
<Spinner size="lg" class="text-primary" />
</div>
<template v-else-if="detail">
<!-- Header -->
<div class="mb-6">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 class="text-xl font-bold text-foreground">{{ detail.display_name }}</h2>
<div class="flex items-center gap-3 mt-1 flex-wrap">
<span v-if="detail.client" class="text-sm text-muted-foreground">
{{ detail.client }}
</span>
<span
v-if="detail.job_number"
class="text-xs bg-muted text-muted-foreground px-2 py-1 rounded"
>
{{ detail.job_number }}
</span>
<a
v-if="detail.repo_url"
:href="detail.repo_url"
target="_blank"
class="text-xs text-primary hover:underline"
>
Repository
</a>
</div>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-foreground">{{ formatDuration(detail.total_hours) }}</p>
<p class="text-xs text-muted-foreground">total hours</p>
</div>
</div>
</div>
<!-- Timeline chart -->
<Card class="mb-6">
<CardHeader class="pb-2">
<CardTitle class="text-sm">Daily Activity</CardTitle>
</CardHeader>
<CardContent>
<div class="h-32 flex items-end gap-px">
<div
v-for="point in detail.timeline"
:key="point.date"
class="flex-1 bg-primary/70 hover:bg-primary rounded-t transition-colors"
:style="{ height: `${(point.hours / maxTimelineHours()) * 100}%` }"
:title="`${point.date}: ${formatDuration(point.hours)}`"
/>
</div>
</CardContent>
</Card>
<!-- Two column layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Top files -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Top Files</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.top_files.length" class="text-sm text-muted-foreground">No data</div>
<div v-else class="space-y-1.5">
<div
v-for="file in detail.top_files.slice(0, 10)"
:key="file.path"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground truncate max-w-[200px]" :title="file.path">
{{ file.path.split('/').pop() }}
</span>
<span class="text-foreground shrink-0 ml-2">{{ file.count }}×</span>
</div>
</div>
</CardContent>
</Card>
<!-- Top tools -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Tool Usage</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.top_tools.length" class="text-sm text-muted-foreground">No data</div>
<div v-else class="space-y-2">
<div
v-for="tool in detail.top_tools.slice(0, 8)"
:key="tool.tool"
class="flex items-center gap-2"
>
<span class="text-xs text-foreground w-24 truncate shrink-0">{{ tool.tool }}</span>
<div class="flex-1 h-2 bg-secondary rounded-full overflow-hidden">
<div
class="h-full bg-primary rounded-full"
:style="{ width: `${tool.pct}%` }"
/>
</div>
<span class="text-xs text-muted-foreground w-8 text-right shrink-0">
{{ tool.pct.toFixed(0) }}%
</span>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Sessions -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm">Recent Sessions</CardTitle>
</CardHeader>
<CardContent>
<div v-if="!detail.sessions.length" class="text-sm text-muted-foreground">No sessions</div>
<div v-else class="space-y-2">
<div
v-for="session in detail.sessions.slice(0, 50)"
:key="session.id"
class="flex items-start gap-3 py-2 border-b border-border last:border-0"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-foreground">{{ formatDateTime(session.start_at) }}</p>
<p v-if="session.summary" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ session.summary }}
</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-foreground">{{ formatDuration(session.duration_hours) }}</p>
<p class="text-xs text-muted-foreground">
{{ session.commit_count }} commits
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<div v-else class="text-center text-muted-foreground py-12">
Project not found
</div>
</div>
</template>

View file

@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { dashboardApi } from '@/api/endpoints/dashboard'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Progress from '@/components/ui/Progress.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { formatDuration, formatDate } from '@/lib/utils'
import type { ProjectSummary } from '@/types'
const router = useRouter()
const projects = ref<ProjectSummary[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await dashboardApi.projects({})
projects.value = res.data.sort((a, b) => b.total_hours - a.total_hours)
} finally {
loading.value = false
}
})
const progressColor = (pct: number | null) => {
if (!pct) return 'default'
if (pct > 90) return 'danger'
if (pct > 70) return 'warning'
return 'success'
}
</script>
<template>
<div class="p-6">
<h2 class="text-lg font-semibold text-foreground mb-6">Projects</h2>
<div v-if="loading" class="flex items-center justify-center h-40">
<Spinner size="lg" class="text-primary" />
</div>
<div v-else-if="projects.length === 0" class="text-center text-muted-foreground py-12">
No projects found
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<Card
v-for="proj in projects"
:key="proj.project_id"
class="cursor-pointer hover:border-primary/50 transition-colors"
@click="router.push(`/projects/${proj.project_id}`)"
>
<CardContent class="p-4">
<!-- Header -->
<div class="flex items-start justify-between gap-2 mb-3">
<div class="min-w-0">
<p class="font-semibold text-sm text-foreground truncate">{{ proj.display_name }}</p>
<p v-if="proj.client" class="text-xs text-muted-foreground truncate">{{ proj.client }}</p>
</div>
<span
v-if="proj.job_number"
class="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0"
>
{{ proj.job_number }}
</span>
</div>
<!-- Stats -->
<div class="space-y-1.5">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">Total hours</span>
<span class="font-medium text-foreground">{{ formatDuration(proj.total_hours) }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">Sessions</span>
<span class="text-foreground">{{ proj.session_count }}</span>
</div>
<div v-if="proj.last_active" class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">Last active</span>
<span class="text-foreground">{{ formatDate(proj.last_active) }}</span>
</div>
</div>
<!-- Budget progress -->
<div v-if="proj.progress_pct !== null" class="mt-3">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">Budget</span>
<span :class="proj.progress_pct > 90 ? 'text-red-400' : 'text-muted-foreground'">
{{ proj.progress_pct.toFixed(0) }}%
</span>
</div>
<Progress
:value="proj.progress_pct"
:color="progressColor(proj.progress_pct)"
/>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View file

@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { reportsApi } from '@/api/endpoints/reports'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Spinner from '@/components/ui/Spinner.vue'
import { toast } from 'vue-sonner'
import { formatDate, isoDateStr } from '@/lib/utils'
import { marked } from 'marked'
import type { AiReport } from '@/types'
const reports = ref<AiReport[]>([])
const loading = ref(false)
const generating = ref(false)
const expandedId = ref<string | null>(null)
const generateType = ref<'daily' | 'weekly'>('daily')
onMounted(() => loadReports())
async function loadReports() {
loading.value = true
try {
const res = await reportsApi.list()
reports.value = res.data
} finally {
loading.value = false
}
}
async function generate() {
generating.value = true
try {
await reportsApi.generate({
type: generateType.value,
period_date: isoDateStr(new Date()),
})
toast.success('Report generated')
await loadReports()
} catch {
toast.error('Failed to generate report')
} finally {
generating.value = false
}
}
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
function renderMarkdown(md: string): string {
return marked(md) as string
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-3 mb-6 flex-wrap">
<h2 class="text-lg font-semibold text-foreground flex-1">AI Reports</h2>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md border border-border overflow-hidden">
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
generateType === 'daily'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted',
]"
@click="generateType = 'daily'"
>Daily</button>
<button
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
generateType === 'weekly'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted',
]"
@click="generateType = 'weekly'"
>Weekly</button>
</div>
<Button size="sm" :loading="generating" @click="generate">
Generate Now
</Button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center h-20">
<Spinner class="text-primary" />
</div>
<!-- Reports list -->
<div v-else-if="reports.length === 0" class="text-center text-muted-foreground py-12 text-sm">
No reports generated yet
</div>
<div v-else class="space-y-3">
<Card v-for="report in reports" :key="report.id">
<CardContent class="p-4">
<!-- Report header -->
<div
class="flex items-start justify-between gap-3 cursor-pointer"
@click="toggleExpand(report.id)"
>
<div class="flex items-center gap-2 flex-wrap">
<Badge :variant="report.type === 'daily' ? 'default' : 'secondary'" class="text-xs">
{{ report.type }}
</Badge>
<span class="text-sm font-medium text-foreground">
{{ formatDate(report.period_date) }}
</span>
<Badge v-if="report.email_sent" variant="success" class="text-xs">
Email sent
</Badge>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-muted-foreground">
{{ new Date(report.generated_at).toLocaleString() }}
</span>
<svg
:class="['h-4 w-4 text-muted-foreground transition-transform', expandedId === report.id ? 'rotate-180' : '']"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Expanded content -->
<div v-if="expandedId === report.id" class="mt-4 pt-4 border-t border-border">
<div
class="prose prose-sm prose-invert max-w-none text-sm text-foreground"
v-html="renderMarkdown(report.content_markdown)"
/>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<style scoped>
:deep(.prose) {
color: hsl(var(--foreground));
}
:deep(.prose h1, .prose h2, .prose h3) {
color: hsl(var(--foreground));
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
:deep(.prose p) {
margin-bottom: 0.75rem;
color: hsl(var(--muted-foreground));
}
:deep(.prose ul, .prose ol) {
margin-left: 1.25rem;
color: hsl(var(--muted-foreground));
}
:deep(.prose li) {
margin-bottom: 0.25rem;
}
:deep(.prose code) {
background: hsl(var(--muted));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.85em;
}
</style>

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useDevopsStore } from '@/stores/devops'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Input from '@/components/ui/Input.vue'
import Button from '@/components/ui/Button.vue'
import { toast } from 'vue-sonner'
import { downloadCsv, downloadIcs } from '@/api/endpoints/exports'
import { isoDateStr } from '@/lib/utils'
import apiClient from '@/api/client'
const authStore = useAuthStore()
const devopsStore = useDevopsStore()
// Profile
const username = ref('')
const dailyOverhead = ref(0)
const savingProfile = ref(false)
// ADO
const adoOrg = ref('')
const adoProject = ref('')
const adoPat = ref('')
const savingAdo = ref(false)
// Export
const exportFrom = ref('')
const exportTo = ref('')
onMounted(() => {
if (authStore.user) {
username.value = authStore.user.username
dailyOverhead.value = authStore.user.daily_overhead_hours ?? 0
}
devopsStore.fetchIntegration().then(() => {
if (devopsStore.integration) {
adoOrg.value = devopsStore.integration.org
adoProject.value = devopsStore.integration.project
}
})
const now = new Date()
exportTo.value = isoDateStr(now)
const past = new Date(now)
past.setDate(now.getDate() - 30)
exportFrom.value = isoDateStr(past)
})
async function saveProfile() {
savingProfile.value = true
try {
await apiClient.patch('/api/auth/me', {
username: username.value,
daily_overhead_hours: dailyOverhead.value,
})
await authStore.fetchMe()
toast.success('Profile saved')
} catch {
toast.error('Failed to save profile')
} finally {
savingProfile.value = false
}
}
async function saveAdo() {
if (!adoOrg.value || !adoProject.value || !adoPat.value) {
toast.error('All ADO fields are required')
return
}
savingAdo.value = true
try {
await devopsStore.saveIntegration({
org: adoOrg.value,
project: adoProject.value,
pat: adoPat.value,
})
adoPat.value = ''
toast.success('Integration saved')
} catch {
toast.error('Failed to save integration')
} finally {
savingAdo.value = false
}
}
async function deleteAdo() {
if (!confirm('Delete ADO integration?')) return
try {
await devopsStore.deleteIntegration()
adoOrg.value = ''
adoProject.value = ''
adoPat.value = ''
toast.success('Integration deleted')
} catch {
toast.error('Failed to delete integration')
}
}
async function syncNow() {
try {
await devopsStore.sync()
toast.success('Sync complete')
} catch {
toast.error(devopsStore.error ?? 'Sync failed')
}
}
</script>
<template>
<div class="p-6 space-y-6 max-w-2xl">
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
<!-- Profile -->
<Card>
<CardHeader>
<CardTitle class="text-sm">Profile</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Username</label>
<Input v-model="username" placeholder="username" />
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Daily Overhead Hours</label>
<Input v-model="dailyOverhead" type="number" min="0" max="8" step="0.25" class="w-32" />
<p class="text-xs text-muted-foreground">
Hours per day to add for overhead / meetings
</p>
</div>
<Button :loading="savingProfile" @click="saveProfile">Save Profile</Button>
</CardContent>
</Card>
<!-- Azure DevOps Integration -->
<Card>
<CardHeader>
<CardTitle class="text-sm">Azure DevOps Integration</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="devopsStore.integration" class="text-xs text-muted-foreground space-y-1">
<p>
Connected to <strong class="text-foreground">{{ devopsStore.integration.org }}</strong>
/ <strong class="text-foreground">{{ devopsStore.integration.project }}</strong>
</p>
<p v-if="devopsStore.integration.last_synced_at">
Last synced: {{ new Date(devopsStore.integration.last_synced_at).toLocaleString() }}
</p>
<p v-if="devopsStore.integration.last_sync_error" class="text-red-400">
Error: {{ devopsStore.integration.last_sync_error }}
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Organization</label>
<Input v-model="adoOrg" placeholder="myorg" />
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Project</label>
<Input v-model="adoProject" placeholder="myproject" />
</div>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">
Personal Access Token {{ devopsStore.integration ? '(leave blank to keep current)' : '' }}
</label>
<Input v-model="adoPat" type="password" placeholder="••••••••" autocomplete="new-password" />
</div>
<div class="flex items-center gap-2">
<Button :loading="savingAdo" @click="saveAdo">
{{ devopsStore.integration ? 'Update' : 'Connect' }}
</Button>
<Button
v-if="devopsStore.integration"
variant="outline"
:loading="devopsStore.syncing"
@click="syncNow"
>
Sync Now
</Button>
<Button
v-if="devopsStore.integration"
variant="destructive"
size="sm"
@click="deleteAdo"
>
Disconnect
</Button>
</div>
</CardContent>
</Card>
<!-- Export -->
<Card>
<CardHeader>
<CardTitle class="text-sm">Export</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<div class="space-y-1.5">
<label class="text-xs text-muted-foreground">From</label>
<Input v-model="exportFrom" type="date" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted-foreground">To</label>
<Input v-model="exportTo" type="date" class="h-8 text-xs" />
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="downloadCsv(exportFrom, exportTo)">
Download CSV
</Button>
<Button variant="outline" size="sm" @click="downloadIcs(exportFrom, exportTo)">
Download ICS
</Button>
</div>
</CardContent>
</Card>
</div>
</template>

49
web/tailwind.config.ts Normal file
View file

@ -0,0 +1,49 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
export default config

25
web/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}

31
web/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
outDir: '../src/static',
emptyOutDir: true
},
server: {
port: 5173,
proxy: {
'/cc-dashboard/api': {
target: 'http://localhost:8800',
changeOrigin: true,
timeout: 300000
},
'/cc-dashboard/events': {
target: 'http://localhost:8800',
changeOrigin: true
}
}
},
base: '/cc-dashboard/'
})

14
web/vitest.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})