From ff52d502b825f1cca2ee4f120c989e4c93d0b4e2 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 6 May 2026 18:52:43 +0100 Subject: [PATCH] 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 --- web/.eslintrc.cjs | 21 ++ web/index.html | 13 + web/package.json | 52 ++++ web/postcss.config.js | 6 + web/src/App.vue | 17 ++ web/src/api/client.ts | 34 +++ web/src/api/endpoints/admin.ts | 16 + web/src/api/endpoints/budgets.ts | 23 ++ web/src/api/endpoints/dashboard.ts | 44 +++ web/src/api/endpoints/devops.ts | 27 ++ web/src/api/endpoints/exports.ts | 15 + web/src/api/endpoints/manual-entries.ts | 24 ++ web/src/api/endpoints/reports.ts | 13 + web/src/api/endpoints/tags.ts | 27 ++ web/src/api/endpoints/tasks.ts | 56 ++++ web/src/components/calendar/CalendarBlock.vue | 93 ++++++ web/src/components/calendar/CalendarGrid.vue | 135 ++++++++ .../components/calendar/CalendarToolbar.vue | 92 ++++++ .../components/calendar/PlannerSidebar.vue | 123 ++++++++ web/src/components/dashboard/KpiCard.vue | 96 ++++++ web/src/components/shared/AppLayout.vue | 67 ++++ web/src/components/shared/Sidebar.vue | 114 +++++++ web/src/components/shared/TopBar.vue | 66 ++++ web/src/components/tasks/TaskCard.vue | 113 +++++++ web/src/components/tasks/TaskForm.vue | 178 +++++++++++ web/src/components/tasks/TaskList.vue | 75 +++++ web/src/components/ui/Avatar.vue | 42 +++ web/src/components/ui/Badge.vue | 30 ++ web/src/components/ui/Button.vue | 63 ++++ web/src/components/ui/Card.vue | 15 + web/src/components/ui/CardContent.vue | 10 + web/src/components/ui/CardHeader.vue | 10 + web/src/components/ui/CardTitle.vue | 10 + web/src/components/ui/Checkbox.vue | 36 +++ web/src/components/ui/Dialog.vue | 84 +++++ web/src/components/ui/Input.vue | 51 ++++ web/src/components/ui/Progress.vue | 30 ++ web/src/components/ui/Select.vue | 36 +++ web/src/components/ui/Spinner.vue | 33 ++ web/src/components/ui/Textarea.vue | 34 +++ web/src/composables/useCalendarDnD.ts | 170 +++++++++++ web/src/composables/useSSE.ts | 105 +++++++ web/src/lib/calendar.ts | 93 ++++++ web/src/lib/color.ts | 22 ++ web/src/lib/utils.ts | 45 +++ web/src/main.ts | 27 ++ web/src/router/index.ts | 100 ++++++ web/src/stores/auth.ts | 72 +++++ web/src/stores/calendar.ts | 110 +++++++ web/src/stores/devops.ts | 75 +++++ web/src/stores/tasks.ts | 93 ++++++ web/src/styles/globals.css | 73 +++++ web/src/tests/calendar.test.ts | 81 +++++ web/src/types/index.ts | 192 ++++++++++++ web/src/views/AdminView.vue | 80 +++++ web/src/views/CalendarView.vue | 117 +++++++ web/src/views/DashboardView.vue | 288 ++++++++++++++++++ web/src/views/KeysView.vue | 141 +++++++++ web/src/views/LiveView.vue | 122 ++++++++ web/src/views/LoginView.vue | 92 ++++++ web/src/views/PlannerView.vue | 152 +++++++++ web/src/views/ProjectDetailView.vue | 178 +++++++++++ web/src/views/ProjectsView.vue | 101 ++++++ web/src/views/ReportsView.vue | 172 +++++++++++ web/src/views/SettingsView.vue | 226 ++++++++++++++ web/tailwind.config.ts | 49 +++ web/tsconfig.json | 25 ++ web/tsconfig.node.json | 11 + web/vite.config.ts | 31 ++ web/vitest.config.ts | 14 + 70 files changed, 5081 insertions(+) create mode 100644 web/.eslintrc.cjs create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/src/App.vue create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/endpoints/admin.ts create mode 100644 web/src/api/endpoints/budgets.ts create mode 100644 web/src/api/endpoints/dashboard.ts create mode 100644 web/src/api/endpoints/devops.ts create mode 100644 web/src/api/endpoints/exports.ts create mode 100644 web/src/api/endpoints/manual-entries.ts create mode 100644 web/src/api/endpoints/reports.ts create mode 100644 web/src/api/endpoints/tags.ts create mode 100644 web/src/api/endpoints/tasks.ts create mode 100644 web/src/components/calendar/CalendarBlock.vue create mode 100644 web/src/components/calendar/CalendarGrid.vue create mode 100644 web/src/components/calendar/CalendarToolbar.vue create mode 100644 web/src/components/calendar/PlannerSidebar.vue create mode 100644 web/src/components/dashboard/KpiCard.vue create mode 100644 web/src/components/shared/AppLayout.vue create mode 100644 web/src/components/shared/Sidebar.vue create mode 100644 web/src/components/shared/TopBar.vue create mode 100644 web/src/components/tasks/TaskCard.vue create mode 100644 web/src/components/tasks/TaskForm.vue create mode 100644 web/src/components/tasks/TaskList.vue create mode 100644 web/src/components/ui/Avatar.vue create mode 100644 web/src/components/ui/Badge.vue create mode 100644 web/src/components/ui/Button.vue create mode 100644 web/src/components/ui/Card.vue create mode 100644 web/src/components/ui/CardContent.vue create mode 100644 web/src/components/ui/CardHeader.vue create mode 100644 web/src/components/ui/CardTitle.vue create mode 100644 web/src/components/ui/Checkbox.vue create mode 100644 web/src/components/ui/Dialog.vue create mode 100644 web/src/components/ui/Input.vue create mode 100644 web/src/components/ui/Progress.vue create mode 100644 web/src/components/ui/Select.vue create mode 100644 web/src/components/ui/Spinner.vue create mode 100644 web/src/components/ui/Textarea.vue create mode 100644 web/src/composables/useCalendarDnD.ts create mode 100644 web/src/composables/useSSE.ts create mode 100644 web/src/lib/calendar.ts create mode 100644 web/src/lib/color.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/stores/auth.ts create mode 100644 web/src/stores/calendar.ts create mode 100644 web/src/stores/devops.ts create mode 100644 web/src/stores/tasks.ts create mode 100644 web/src/styles/globals.css create mode 100644 web/src/tests/calendar.test.ts create mode 100644 web/src/types/index.ts create mode 100644 web/src/views/AdminView.vue create mode 100644 web/src/views/CalendarView.vue create mode 100644 web/src/views/DashboardView.vue create mode 100644 web/src/views/KeysView.vue create mode 100644 web/src/views/LiveView.vue create mode 100644 web/src/views/LoginView.vue create mode 100644 web/src/views/PlannerView.vue create mode 100644 web/src/views/ProjectDetailView.vue create mode 100644 web/src/views/ProjectsView.vue create mode 100644 web/src/views/ReportsView.vue create mode 100644 web/src/views/SettingsView.vue create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..9fe5f9c --- /dev/null +++ b/web/.eslintrc.cjs @@ -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', + }, +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a7db225 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CC Dashboard + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c141333 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..f285369 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..d64f493 --- /dev/null +++ b/web/src/api/client.ts @@ -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 diff --git a/web/src/api/endpoints/admin.ts b/web/src/api/endpoints/admin.ts new file mode 100644 index 0000000..b3d7652 --- /dev/null +++ b/web/src/api/endpoints/admin.ts @@ -0,0 +1,16 @@ +import apiClient from '@/api/client' +import type { UserOut, ApiKey } from '@/types' + +export const adminApi = { + users: () => + apiClient.get('/api/admin/users'), + + keys: () => + apiClient.get('/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}`), +} diff --git a/web/src/api/endpoints/budgets.ts b/web/src/api/endpoints/budgets.ts new file mode 100644 index 0000000..d429b53 --- /dev/null +++ b/web/src/api/endpoints/budgets.ts @@ -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('/api/budgets'), + + create: (payload: BudgetPayload) => + apiClient.post('/api/budgets', payload), + + update: (id: string, payload: Partial) => + apiClient.patch(`/api/budgets/${id}`, payload), + + remove: (id: string) => + apiClient.delete(`/api/budgets/${id}`), +} diff --git a/web/src/api/endpoints/dashboard.ts b/web/src/api/endpoints/dashboard.ts new file mode 100644 index 0000000..c7dc31a --- /dev/null +++ b/web/src/api/endpoints/dashboard.ts @@ -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('/api/dashboard/summary', { params }), + + projects: (params: DashboardParams) => + apiClient.get('/api/dashboard/projects', { params }), + + timeline: (params: DashboardParams) => + apiClient.get('/api/dashboard/timeline', { params }), + + monthly: (params: DashboardParams) => + apiClient.get('/api/dashboard/monthly', { params }), + + dow: (params: DashboardParams) => + apiClient.get('/api/dashboard/dow', { params }), + + tools: (params: DashboardParams) => + apiClient.get('/api/dashboard/tools', { params }), + + activity: (params: DashboardParams & { limit?: number }) => + apiClient.get('/api/dashboard/activity', { params }), + + calendar: (params: { from: string; to: string; view: 'week' | 'day' }) => + apiClient.get('/api/dashboard/calendar', { params }), + + project: (id: string, params?: DashboardParams) => + apiClient.get('/api/dashboard/project/' + id, { params }), +} diff --git a/web/src/api/endpoints/devops.ts b/web/src/api/endpoints/devops.ts new file mode 100644 index 0000000..459c6d6 --- /dev/null +++ b/web/src/api/endpoints/devops.ts @@ -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('/api/devops/integration'), + + saveIntegration: (payload: IntegrationPayload) => + apiClient.put('/api/devops/integration', payload), + + deleteIntegration: () => + apiClient.delete('/api/devops/integration'), + + sync: () => + apiClient.post('/api/devops/sync'), + + workItems: (state?: string) => + apiClient.get('/api/devops/work-items', { + params: state ? { state } : undefined, + }), +} diff --git a/web/src/api/endpoints/exports.ts b/web/src/api/endpoints/exports.ts new file mode 100644 index 0000000..3f77363 --- /dev/null +++ b/web/src/api/endpoints/exports.ts @@ -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() +} diff --git a/web/src/api/endpoints/manual-entries.ts b/web/src/api/endpoints/manual-entries.ts new file mode 100644 index 0000000..0d1222a --- /dev/null +++ b/web/src/api/endpoints/manual-entries.ts @@ -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('/api/manual-entries', { params }), + + create: (payload: ManualEntryPayload) => + apiClient.post('/api/manual-entries', payload), + + update: (id: string, payload: Partial) => + apiClient.patch(`/api/manual-entries/${id}`, payload), + + remove: (id: string) => + apiClient.delete(`/api/manual-entries/${id}`), +} diff --git a/web/src/api/endpoints/reports.ts b/web/src/api/endpoints/reports.ts new file mode 100644 index 0000000..270f475 --- /dev/null +++ b/web/src/api/endpoints/reports.ts @@ -0,0 +1,13 @@ +import apiClient from '@/api/client' +import type { AiReport } from '@/types' + +export const reportsApi = { + list: () => + apiClient.get('/api/reports'), + + get: (id: string) => + apiClient.get(`/api/reports/${id}`), + + generate: (payload: { type: 'daily' | 'weekly'; period_date: string }) => + apiClient.post('/api/reports/generate', payload), +} diff --git a/web/src/api/endpoints/tags.ts b/web/src/api/endpoints/tags.ts new file mode 100644 index 0000000..ad95c5d --- /dev/null +++ b/web/src/api/endpoints/tags.ts @@ -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('/api/tags'), + + create: (payload: TagPayload) => + apiClient.post('/api/tags', payload), + + update: (id: string, payload: Partial) => + apiClient.patch(`/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}`), +} diff --git a/web/src/api/endpoints/tasks.ts b/web/src/api/endpoints/tasks.ts new file mode 100644 index 0000000..1b43e56 --- /dev/null +++ b/web/src/api/endpoints/tasks.ts @@ -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 { + sort_index?: number +} + +export interface BlockCreatePayload { + start_at: string + end_at: string +} + +export interface BlockUpdatePayload extends Partial {} + +export const tasksApi = { + list: (params?: { date?: string; project_id?: string }) => + apiClient.get('/api/tasks', { params }), + + get: (id: string) => + apiClient.get(`/api/tasks/${id}`), + + create: (payload: TaskCreatePayload) => + apiClient.post('/api/tasks', payload), + + update: (id: string, payload: TaskUpdatePayload) => + apiClient.patch(`/api/tasks/${id}`, payload), + + remove: (id: string) => + apiClient.delete(`/api/tasks/${id}`), + + complete: (id: string) => + apiClient.post(`/api/tasks/${id}/complete`), + + blocks: (taskId: string) => + apiClient.get(`/api/tasks/${taskId}/blocks`), + + createBlock: (taskId: string, payload: BlockCreatePayload) => + apiClient.post(`/api/tasks/${taskId}/blocks`, payload), + + updateBlock: (id: string, payload: BlockUpdatePayload) => + apiClient.patch(`/api/tasks/blocks/${id}`, payload), + + deleteBlock: (id: string) => + apiClient.delete(`/api/tasks/blocks/${id}`), +} diff --git a/web/src/components/calendar/CalendarBlock.vue b/web/src/components/calendar/CalendarBlock.vue new file mode 100644 index 0000000..2cb6cbe --- /dev/null +++ b/web/src/components/calendar/CalendarBlock.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web/src/components/calendar/CalendarGrid.vue b/web/src/components/calendar/CalendarGrid.vue new file mode 100644 index 0000000..e4a5a96 --- /dev/null +++ b/web/src/components/calendar/CalendarGrid.vue @@ -0,0 +1,135 @@ + + + diff --git a/web/src/components/calendar/CalendarToolbar.vue b/web/src/components/calendar/CalendarToolbar.vue new file mode 100644 index 0000000..6167e54 --- /dev/null +++ b/web/src/components/calendar/CalendarToolbar.vue @@ -0,0 +1,92 @@ + + + diff --git a/web/src/components/calendar/PlannerSidebar.vue b/web/src/components/calendar/PlannerSidebar.vue new file mode 100644 index 0000000..51062db --- /dev/null +++ b/web/src/components/calendar/PlannerSidebar.vue @@ -0,0 +1,123 @@ + + + diff --git a/web/src/components/dashboard/KpiCard.vue b/web/src/components/dashboard/KpiCard.vue new file mode 100644 index 0000000..6a0adf8 --- /dev/null +++ b/web/src/components/dashboard/KpiCard.vue @@ -0,0 +1,96 @@ + + + diff --git a/web/src/components/shared/AppLayout.vue b/web/src/components/shared/AppLayout.vue new file mode 100644 index 0000000..77068dd --- /dev/null +++ b/web/src/components/shared/AppLayout.vue @@ -0,0 +1,67 @@ + + + diff --git a/web/src/components/shared/Sidebar.vue b/web/src/components/shared/Sidebar.vue new file mode 100644 index 0000000..3954d6d --- /dev/null +++ b/web/src/components/shared/Sidebar.vue @@ -0,0 +1,114 @@ + + + diff --git a/web/src/components/shared/TopBar.vue b/web/src/components/shared/TopBar.vue new file mode 100644 index 0000000..92fbe58 --- /dev/null +++ b/web/src/components/shared/TopBar.vue @@ -0,0 +1,66 @@ + + + diff --git a/web/src/components/tasks/TaskCard.vue b/web/src/components/tasks/TaskCard.vue new file mode 100644 index 0000000..5c4e5da --- /dev/null +++ b/web/src/components/tasks/TaskCard.vue @@ -0,0 +1,113 @@ + + + diff --git a/web/src/components/tasks/TaskForm.vue b/web/src/components/tasks/TaskForm.vue new file mode 100644 index 0000000..8d30158 --- /dev/null +++ b/web/src/components/tasks/TaskForm.vue @@ -0,0 +1,178 @@ + + +