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:
parent
1071ac2f4d
commit
ff52d502b8
70 changed files with 5081 additions and 0 deletions
21
web/.eslintrc.cjs
Normal file
21
web/.eslintrc.cjs
Normal 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
13
web/index.html
Normal 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
52
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
17
web/src/App.vue
Normal file
17
web/src/App.vue
Normal 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
34
web/src/api/client.ts
Normal 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
|
||||
16
web/src/api/endpoints/admin.ts
Normal file
16
web/src/api/endpoints/admin.ts
Normal 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}`),
|
||||
}
|
||||
23
web/src/api/endpoints/budgets.ts
Normal file
23
web/src/api/endpoints/budgets.ts
Normal 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}`),
|
||||
}
|
||||
44
web/src/api/endpoints/dashboard.ts
Normal file
44
web/src/api/endpoints/dashboard.ts
Normal 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 }),
|
||||
}
|
||||
27
web/src/api/endpoints/devops.ts
Normal file
27
web/src/api/endpoints/devops.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
15
web/src/api/endpoints/exports.ts
Normal file
15
web/src/api/endpoints/exports.ts
Normal 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()
|
||||
}
|
||||
24
web/src/api/endpoints/manual-entries.ts
Normal file
24
web/src/api/endpoints/manual-entries.ts
Normal 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}`),
|
||||
}
|
||||
13
web/src/api/endpoints/reports.ts
Normal file
13
web/src/api/endpoints/reports.ts
Normal 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),
|
||||
}
|
||||
27
web/src/api/endpoints/tags.ts
Normal file
27
web/src/api/endpoints/tags.ts
Normal 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}`),
|
||||
}
|
||||
56
web/src/api/endpoints/tasks.ts
Normal file
56
web/src/api/endpoints/tasks.ts
Normal 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}`),
|
||||
}
|
||||
93
web/src/components/calendar/CalendarBlock.vue
Normal file
93
web/src/components/calendar/CalendarBlock.vue
Normal 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>
|
||||
135
web/src/components/calendar/CalendarGrid.vue
Normal file
135
web/src/components/calendar/CalendarGrid.vue
Normal 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>
|
||||
92
web/src/components/calendar/CalendarToolbar.vue
Normal file
92
web/src/components/calendar/CalendarToolbar.vue
Normal 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>
|
||||
123
web/src/components/calendar/PlannerSidebar.vue
Normal file
123
web/src/components/calendar/PlannerSidebar.vue
Normal 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>
|
||||
96
web/src/components/dashboard/KpiCard.vue
Normal file
96
web/src/components/dashboard/KpiCard.vue
Normal 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>
|
||||
67
web/src/components/shared/AppLayout.vue
Normal file
67
web/src/components/shared/AppLayout.vue
Normal 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>
|
||||
114
web/src/components/shared/Sidebar.vue
Normal file
114
web/src/components/shared/Sidebar.vue
Normal 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>
|
||||
66
web/src/components/shared/TopBar.vue
Normal file
66
web/src/components/shared/TopBar.vue
Normal 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>
|
||||
113
web/src/components/tasks/TaskCard.vue
Normal file
113
web/src/components/tasks/TaskCard.vue
Normal 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>
|
||||
178
web/src/components/tasks/TaskForm.vue
Normal file
178
web/src/components/tasks/TaskForm.vue
Normal 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>
|
||||
75
web/src/components/tasks/TaskList.vue
Normal file
75
web/src/components/tasks/TaskList.vue
Normal 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>
|
||||
42
web/src/components/ui/Avatar.vue
Normal file
42
web/src/components/ui/Avatar.vue
Normal 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>
|
||||
30
web/src/components/ui/Badge.vue
Normal file
30
web/src/components/ui/Badge.vue
Normal 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>
|
||||
63
web/src/components/ui/Button.vue
Normal file
63
web/src/components/ui/Button.vue
Normal 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>
|
||||
15
web/src/components/ui/Card.vue
Normal file
15
web/src/components/ui/Card.vue
Normal 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>
|
||||
10
web/src/components/ui/CardContent.vue
Normal file
10
web/src/components/ui/CardContent.vue
Normal 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>
|
||||
10
web/src/components/ui/CardHeader.vue
Normal file
10
web/src/components/ui/CardHeader.vue
Normal 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>
|
||||
10
web/src/components/ui/CardTitle.vue
Normal file
10
web/src/components/ui/CardTitle.vue
Normal 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>
|
||||
36
web/src/components/ui/Checkbox.vue
Normal file
36
web/src/components/ui/Checkbox.vue
Normal 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>
|
||||
84
web/src/components/ui/Dialog.vue
Normal file
84
web/src/components/ui/Dialog.vue
Normal 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>
|
||||
51
web/src/components/ui/Input.vue
Normal file
51
web/src/components/ui/Input.vue
Normal 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>
|
||||
30
web/src/components/ui/Progress.vue
Normal file
30
web/src/components/ui/Progress.vue
Normal 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>
|
||||
36
web/src/components/ui/Select.vue
Normal file
36
web/src/components/ui/Select.vue
Normal 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>
|
||||
33
web/src/components/ui/Spinner.vue
Normal file
33
web/src/components/ui/Spinner.vue
Normal 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>
|
||||
34
web/src/components/ui/Textarea.vue
Normal file
34
web/src/components/ui/Textarea.vue
Normal 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>
|
||||
170
web/src/composables/useCalendarDnD.ts
Normal file
170
web/src/composables/useCalendarDnD.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
105
web/src/composables/useSSE.ts
Normal file
105
web/src/composables/useSSE.ts
Normal 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
93
web/src/lib/calendar.ts
Normal 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
22
web/src/lib/color.ts
Normal 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
45
web/src/lib/utils.ts
Normal 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
27
web/src/main.ts
Normal 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
100
web/src/router/index.ts
Normal 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
72
web/src/stores/auth.ts
Normal 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
110
web/src/stores/calendar.ts
Normal 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
75
web/src/stores/devops.ts
Normal 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
93
web/src/stores/tasks.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
73
web/src/styles/globals.css
Normal file
73
web/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
81
web/src/tests/calendar.test.ts
Normal file
81
web/src/tests/calendar.test.ts
Normal 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
192
web/src/types/index.ts
Normal 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
|
||||
}
|
||||
80
web/src/views/AdminView.vue
Normal file
80
web/src/views/AdminView.vue
Normal 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>
|
||||
117
web/src/views/CalendarView.vue
Normal file
117
web/src/views/CalendarView.vue
Normal 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>
|
||||
288
web/src/views/DashboardView.vue
Normal file
288
web/src/views/DashboardView.vue
Normal 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
141
web/src/views/KeysView.vue
Normal 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
122
web/src/views/LiveView.vue
Normal 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>
|
||||
92
web/src/views/LoginView.vue
Normal file
92
web/src/views/LoginView.vue
Normal 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>
|
||||
152
web/src/views/PlannerView.vue
Normal file
152
web/src/views/PlannerView.vue
Normal 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>
|
||||
178
web/src/views/ProjectDetailView.vue
Normal file
178
web/src/views/ProjectDetailView.vue
Normal 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>
|
||||
101
web/src/views/ProjectsView.vue
Normal file
101
web/src/views/ProjectsView.vue
Normal 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>
|
||||
172
web/src/views/ReportsView.vue
Normal file
172
web/src/views/ReportsView.vue
Normal 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>
|
||||
226
web/src/views/SettingsView.vue
Normal file
226
web/src/views/SettingsView.vue
Normal 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
49
web/tailwind.config.ts
Normal 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
25
web/tsconfig.json
Normal 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
11
web/tsconfig.node.json
Normal 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
31
web/vite.config.ts
Normal 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
14
web/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue