feat: updade workspace
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OKR Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@ainative-okr/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"axios": "^1.10.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^24.0.8",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { AppLayout } from './components/layout/AppLayout.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { CreateObjective } from './pages/CreateObjective.js';
|
||||
import { Dashboard } from './pages/Dashboard.js';
|
||||
import { KeyResultDetail } from './pages/KeyResultDetail.js';
|
||||
import { Login } from './pages/Login.js';
|
||||
import { OKRDetail } from './pages/OKRDetail.js';
|
||||
|
||||
function ProtectedRoute(): JSX.Element {
|
||||
const { user } = useAuth();
|
||||
return user === null ? <Navigate to="/login" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function App(): JSX.Element {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="objectives/new" element={<CreateObjective />} />
|
||||
<Route path="objectives/:id" element={<OKRDetail />} />
|
||||
<Route path="key-results/:id" element={<KeyResultDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Badge } from '../components/ui/Badge.js';
|
||||
import { ProgressBar } from '../components/ui/ProgressBar.js';
|
||||
import { createObjectiveSchema } from '../schemas/objective.schema.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: Component render — Badge displays correct label per status
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Badge component', () => {
|
||||
it('renders "In Progress" label for IN_PROGRESS status', () => {
|
||||
render(<Badge status="IN_PROGRESS" />);
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Completed" label for COMPLETED status', () => {
|
||||
render(<Badge status="COMPLETED" />);
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Not Started" label for NOT_STARTED status', () => {
|
||||
render(<Badge status="NOT_STARTED" />);
|
||||
expect(screen.getByText('Not Started')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: ProgressBar clamps value to 0–100 range
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('ProgressBar component', () => {
|
||||
it('clamps value above 100 to 100%', () => {
|
||||
const { container } = render(<ProgressBar value={150} />);
|
||||
const bar = container.querySelector('.bg-blue-600') as HTMLElement;
|
||||
expect(bar.style.width).toBe('100%');
|
||||
});
|
||||
|
||||
it('clamps negative value to 0%', () => {
|
||||
const { container } = render(<ProgressBar value={-10} />);
|
||||
const bar = container.querySelector('.bg-blue-600') as HTMLElement;
|
||||
expect(bar.style.width).toBe('0%');
|
||||
});
|
||||
|
||||
it('renders exact value within range', () => {
|
||||
const { container } = render(<ProgressBar value={75} />);
|
||||
const bar = container.querySelector('.bg-blue-600') as HTMLElement;
|
||||
expect(bar.style.width).toBe('75%');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test 3: Form validation — Zod schema enforces quarter format
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('createObjectiveSchema validation', () => {
|
||||
it('accepts a valid payload', () => {
|
||||
const result = createObjectiveSchema.safeParse({
|
||||
title: 'Improve platform uptime',
|
||||
ownerId: 1,
|
||||
quarter: 'Q2/2026',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid quarter format', () => {
|
||||
const result = createObjectiveSchema.safeParse({
|
||||
title: 'Improve platform uptime',
|
||||
ownerId: 1,
|
||||
quarter: 'Q5/2026',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const quarterError = result.error.issues.find((i) => i.path[0] === 'quarter');
|
||||
expect(quarterError).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty title', () => {
|
||||
const result = createObjectiveSchema.safeParse({
|
||||
title: '',
|
||||
ownerId: 1,
|
||||
quarter: 'Q1/2025',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const titleError = result.error.issues.find((i) => i.path[0] === 'title');
|
||||
expect(titleError).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-positive ownerId', () => {
|
||||
const result = createObjectiveSchema.safeParse({
|
||||
title: 'Valid title',
|
||||
ownerId: -1,
|
||||
quarter: 'Q3/2025',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const ownerError = result.error.issues.find((i) => i.path[0] === 'ownerId');
|
||||
expect(ownerError).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test 4: Progress calculation (same logic as Dashboard.objectiveProgress)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function objectiveProgress(keyResults: { progress: number }[]): number {
|
||||
if (keyResults.length === 0) return 0;
|
||||
return Math.round(keyResults.reduce((total, kr) => total + kr.progress, 0) / keyResults.length);
|
||||
}
|
||||
|
||||
describe('objectiveProgress calculation', () => {
|
||||
it('returns 0 for empty key results', () => {
|
||||
expect(objectiveProgress([])).toBe(0);
|
||||
});
|
||||
|
||||
it('computes average progress across key results', () => {
|
||||
expect(objectiveProgress([{ progress: 50 }, { progress: 100 }])).toBe(75);
|
||||
});
|
||||
|
||||
it('rounds fractional averages', () => {
|
||||
expect(objectiveProgress([{ progress: 33 }, { progress: 34 }, { progress: 34 }])).toBe(34);
|
||||
});
|
||||
|
||||
it('returns 100 when all key results complete', () => {
|
||||
expect(objectiveProgress([{ progress: 100 }, { progress: 100 }])).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Test 5: API error handling — axios mock returns error state
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('API error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports error when API rejects', async () => {
|
||||
const mockGet = vi.fn().mockRejectedValue(new Error('Network Error'));
|
||||
const result = await mockGet('/api/objectives').catch((e: Error) => e);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect((result as Error).message).toBe('Network Error');
|
||||
});
|
||||
|
||||
it('returns data when API resolves', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({ data: { success: true, data: [] } });
|
||||
const result = await mockGet('/api/objectives');
|
||||
expect(result.data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Header } from './Header.js';
|
||||
import { Sidebar } from './Sidebar.js';
|
||||
|
||||
export function AppLayout(): JSX.Element {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<Header />
|
||||
<main className="ml-64 pt-20">
|
||||
<div className="mx-auto max-w-6xl px-6 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { useAuth } from '../../hooks/useAuth.js';
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const { user, logout } = useAuth();
|
||||
return (
|
||||
<header className="fixed left-64 right-0 top-0 z-10 border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<select className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option>All FPT</option>
|
||||
</select>
|
||||
<input
|
||||
className="w-full max-w-xl rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
<Link to="/objectives/new">
|
||||
<Button>NEW OKR</Button>
|
||||
</Link>
|
||||
<div className="text-sm text-gray-500">{user?.name}</div>
|
||||
<Button variant="secondary" onClick={logout}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const links = [
|
||||
{ label: 'My OKRs', href: '/' },
|
||||
{ label: 'I created', href: '/' },
|
||||
{ label: 'I manage', href: '/' },
|
||||
{ label: 'Members', href: '/' },
|
||||
{ label: 'OKR - all', href: '/' },
|
||||
];
|
||||
|
||||
export function Sidebar(): JSX.Element {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 border-r border-gray-200 bg-white p-5">
|
||||
<div className="mb-8 text-xl font-semibold text-blue-600">FOKR</div>
|
||||
<div className="mb-4 text-sm font-medium text-gray-500">2026</div>
|
||||
<nav className="space-y-1">
|
||||
{links.map((link) => {
|
||||
const active = location.pathname === link.href && link.label === 'My OKRs';
|
||||
return (
|
||||
<Link
|
||||
key={link.label}
|
||||
to={link.href}
|
||||
className={`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
active ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ObjectiveStatus } from '../../types/okr.types.js';
|
||||
|
||||
interface BadgeProps {
|
||||
status: ObjectiveStatus;
|
||||
}
|
||||
|
||||
const labels: Record<ObjectiveStatus, string> = {
|
||||
NOT_STARTED: 'Not Started',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
COMPLETED: 'Completed',
|
||||
};
|
||||
|
||||
const classes: Record<ObjectiveStatus, string> = {
|
||||
NOT_STARTED: 'bg-gray-100 text-gray-600',
|
||||
IN_PROGRESS: 'bg-orange-100 text-orange-700',
|
||||
COMPLETED: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
export function Badge({ status }: BadgeProps): JSX.Element {
|
||||
return <span className={`rounded-full px-2 py-1 text-xs font-medium ${classes[status]}`}>{labels[status]}</span>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export function Button({ className = '', variant = 'primary', ...props }: ButtonProps): JSX.Element {
|
||||
const base = 'inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60';
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'border border-gray-300 text-gray-700 hover:bg-gray-50',
|
||||
};
|
||||
return <button className={`${base} ${variants[variant]} ${className}`} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function ProgressBar({ value }: ProgressBarProps): JSX.Element {
|
||||
const width = `${Math.min(Math.max(value, 0), 100)}%`;
|
||||
return (
|
||||
<div className="w-full rounded-full bg-gray-200 h-2">
|
||||
<div className="h-2 rounded-full bg-blue-600 transition-all" style={{ width }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import { login as loginRequest } from '../lib/api.js';
|
||||
import type { User } from '../types/okr.types.js';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
const storedUser = (): User | null => {
|
||||
const raw = window.localStorage.getItem('okr_user');
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw) as User;
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const [user, setUser] = useState<User | null>(storedUser);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
login: async (username: string, password: string) => {
|
||||
const result = await loginRequest(username, password);
|
||||
window.localStorage.setItem('okr_user', JSON.stringify(result.user));
|
||||
setUser(result.user);
|
||||
},
|
||||
logout: () => {
|
||||
window.localStorage.removeItem('okr_user');
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listObjectives } from '../lib/api.js';
|
||||
|
||||
export function useObjectives(quarter: string) {
|
||||
return useQuery({
|
||||
queryKey: ['objectives', { quarter }],
|
||||
queryFn: () => listObjectives(quarter),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f9fafb;
|
||||
color: #1f2937;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateObjectivePayload,
|
||||
KeyResult,
|
||||
LoginResult,
|
||||
Objective,
|
||||
UpdateProgressPayload,
|
||||
User,
|
||||
} from '../types/okr.types.js';
|
||||
|
||||
const apiBaseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL ??
|
||||
`${window.location.protocol}//${window.location.hostname === '127.0.0.1' ? '127.0.0.1' : 'localhost'}:3000/api/v1`;
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const response = await client.post<ApiResponse<LoginResult>>('/auth/login', { username, password });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<User[]> {
|
||||
const response = await client.get<ApiResponse<User[]>>('/users');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function listObjectives(quarter?: string): Promise<Objective[]> {
|
||||
const response = await client.get<ApiResponse<Objective[]>>('/objectives', { params: { quarter } });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getObjective(id: number): Promise<Objective> {
|
||||
const response = await client.get<ApiResponse<Objective>>(`/objectives/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function createObjective(payload: CreateObjectivePayload): Promise<Objective> {
|
||||
const response = await client.post<ApiResponse<Objective>>('/objectives', payload);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getKeyResult(id: number): Promise<KeyResult & { objective: Objective }> {
|
||||
const response = await client.get<ApiResponse<KeyResult & { objective: Objective }>>(`/key-results/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function updateKeyResultProgress(id: number, payload: UpdateProgressPayload): Promise<KeyResult> {
|
||||
const response = await client.patch<ApiResponse<KeyResult>>(`/key-results/${id}/progress`, payload);
|
||||
return response.data.data;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App.js';
|
||||
import { AuthProvider } from './hooks/useAuth.js';
|
||||
import { queryClient } from './lib/queryClient.js';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/Button.js';
|
||||
import { createObjective, listUsers } from '../lib/api.js';
|
||||
import { createObjectiveSchema, type CreateObjectiveFormData } from '../schemas/objective.schema.js';
|
||||
|
||||
export function CreateObjective(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof CreateObjectiveFormData, string>>>({});
|
||||
const { register, handleSubmit } = useForm<CreateObjectiveFormData>({
|
||||
defaultValues: { title: '', description: '', quarter: 'Q2/2026' },
|
||||
});
|
||||
const { data: users = [] } = useQuery({ queryKey: ['users'], queryFn: listUsers });
|
||||
const mutation = useMutation({
|
||||
mutationFn: createObjective,
|
||||
onSuccess: async (objective) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['objectives'] });
|
||||
navigate(`/objectives/${objective.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
const parsed = createObjectiveSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
setFieldErrors(Object.fromEntries(parsed.error.issues.map((issue) => [issue.path[0], issue.message])));
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
mutation.mutate(parsed.data);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="mb-6 text-xl font-semibold text-gray-800">Create Objective</h1>
|
||||
<form className="max-w-2xl space-y-4" onSubmit={onSubmit}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Title</span>
|
||||
<input className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('title')} />
|
||||
{fieldErrors.title !== undefined && <span className="mt-1 block text-sm text-orange-600">{fieldErrors.title}</span>}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Description</span>
|
||||
<textarea rows={4} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('description')} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Owner</span>
|
||||
<select className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('ownerId')}>
|
||||
<option value="">Select owner</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.ownerId !== undefined && <span className="mt-1 block text-sm text-orange-600">{fieldErrors.ownerId}</span>}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Quarter</span>
|
||||
<input className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('quarter')} />
|
||||
{fieldErrors.quarter !== undefined && <span className="mt-1 block text-sm text-orange-600">{fieldErrors.quarter}</span>}
|
||||
</label>
|
||||
</div>
|
||||
{mutation.isError && <div className="rounded-lg bg-orange-100 px-3 py-2 text-sm text-orange-700">Unable to create objective.</div>}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '../components/ui/Badge.js';
|
||||
import { ProgressBar } from '../components/ui/ProgressBar.js';
|
||||
import { useObjectives } from '../hooks/useObjectives.js';
|
||||
|
||||
function objectiveProgress(keyResults: { progress: number }[]): number {
|
||||
if (keyResults.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(keyResults.reduce((total, keyResult) => total + keyResult.progress, 0) / keyResults.length);
|
||||
}
|
||||
|
||||
export function Dashboard(): JSX.Element {
|
||||
const { data: objectives = [], isLoading, error } = useObjectives('Q2/2026');
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-800">OKR List</h1>
|
||||
<p className="text-sm text-gray-500">Q2/2026</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option>Q2/2026</option>
|
||||
</select>
|
||||
<button className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="rounded-xl border border-gray-200 bg-white p-6 text-gray-500">Loading OKRs...</div>}
|
||||
{error !== null && <div className="rounded-xl border border-orange-200 bg-orange-100 p-6 text-orange-700">Unable to load OKRs.</div>}
|
||||
|
||||
<div className="space-y-4">
|
||||
{objectives.map((objective) => {
|
||||
const progress = objectiveProgress(objective.keyResults);
|
||||
return (
|
||||
<Link
|
||||
key={objective.id}
|
||||
to={`/objectives/${objective.id}`}
|
||||
className="block rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition hover:-translate-y-px hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">{objective.title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">Owner: {objective.owner.name}</p>
|
||||
</div>
|
||||
<Badge status={objective.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<ProgressBar value={progress} />
|
||||
</div>
|
||||
<span className="w-12 text-right text-sm font-medium text-gray-700">{progress}%</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/Button.js';
|
||||
import { ProgressBar } from '../components/ui/ProgressBar.js';
|
||||
import { getKeyResult, updateKeyResultProgress } from '../lib/api.js';
|
||||
import { updateProgressSchema, type UpdateProgressFormData } from '../schemas/key-result.schema.js';
|
||||
|
||||
function readRouteId(id: string | undefined): number {
|
||||
const parsed = Number(id);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function KeyResultDetail(): JSX.Element {
|
||||
const id = readRouteId(useParams().id);
|
||||
const queryClient = useQueryClient();
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const { register, handleSubmit, reset } = useForm<UpdateProgressFormData>();
|
||||
const { data: keyResult, isLoading } = useQuery({
|
||||
queryKey: ['key-result', id],
|
||||
queryFn: () => getKeyResult(id),
|
||||
enabled: id > 0,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: UpdateProgressFormData) => updateKeyResultProgress(id, payload),
|
||||
onSuccess: async (updated) => {
|
||||
reset({ progress: updated.progress, comment: '' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['key-result', id] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['objective', keyResult?.objectiveId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['objectives'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || keyResult === undefined) {
|
||||
return <div className="rounded-xl border border-gray-200 bg-white p-6 text-gray-500">Loading key result...</div>;
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
const parsed = updateProgressSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
setFieldError(parsed.error.issues[0]?.message ?? 'Invalid progress');
|
||||
return;
|
||||
}
|
||||
setFieldError(null);
|
||||
mutation.mutate(parsed.data);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="mb-2 text-xl font-semibold text-gray-800">Key Result Detail</h1>
|
||||
<h2 className="text-lg font-medium text-gray-800">{keyResult.title}</h2>
|
||||
<div className="mt-2 text-sm text-gray-500">Owner: {keyResult.objective.owner.name}</div>
|
||||
<div className="mt-1 text-sm text-gray-500">Deadline: {new Date(keyResult.deadline).toLocaleDateString()}</div>
|
||||
|
||||
<div className="my-6 max-w-md">
|
||||
<div className="mb-2 flex justify-between text-sm font-medium text-gray-700">
|
||||
<span>Current Progress</span>
|
||||
<span>{keyResult.progress}%</span>
|
||||
</div>
|
||||
<ProgressBar value={keyResult.progress} />
|
||||
</div>
|
||||
|
||||
<form className="max-w-md space-y-4" onSubmit={onSubmit}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Update Progress</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue={keyResult.progress}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{...register('progress')}
|
||||
/>
|
||||
<span className="text-gray-500">%</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Comment</span>
|
||||
<textarea rows={4} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('comment')} />
|
||||
</label>
|
||||
{fieldError !== null && <div className="rounded-lg bg-orange-100 px-3 py-2 text-sm text-orange-700">{fieldError}</div>}
|
||||
{mutation.isError && <div className="rounded-lg bg-orange-100 px-3 py-2 text-sm text-orange-700">Unable to update progress.</div>}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/Button.js';
|
||||
import { useAuth } from '../hooks/useAuth.js';
|
||||
import { loginSchema, type LoginFormData } from '../schemas/auth.schema.js';
|
||||
|
||||
export function Login(): JSX.Element {
|
||||
const { user, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof LoginFormData, string>>>({});
|
||||
const { register, handleSubmit } = useForm<LoginFormData>({
|
||||
defaultValues: { username: 'employee', password: 'Password@123' },
|
||||
});
|
||||
|
||||
if (user !== null) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const parsed = loginSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
setFieldErrors(Object.fromEntries(parsed.error.issues.map((issue) => [issue.path[0], issue.message])));
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
try {
|
||||
await login(parsed.data.username, parsed.data.password);
|
||||
navigate('/');
|
||||
} catch {
|
||||
setFormError('Invalid username or password');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<section className="w-full max-w-md rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-blue-600 text-lg font-semibold text-white">
|
||||
OKR
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-800">Sign in to OKR</h1>
|
||||
</div>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700">Username or email address</span>
|
||||
<input className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('username')} />
|
||||
{fieldErrors.username !== undefined && <span className="mt-1 block text-sm text-orange-600">{fieldErrors.username}</span>}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 flex justify-between text-sm font-medium text-gray-700">
|
||||
Password <span className="text-blue-600">Forgot password?</span>
|
||||
</span>
|
||||
<input type="password" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" {...register('password')} />
|
||||
{fieldErrors.password !== undefined && <span className="mt-1 block text-sm text-orange-600">{fieldErrors.password}</span>}
|
||||
</label>
|
||||
{formError !== null && <div className="rounded-lg bg-orange-100 px-3 py-2 text-sm text-orange-700">{formError}</div>}
|
||||
<Button className="w-full" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '../components/ui/Badge.js';
|
||||
import { Button } from '../components/ui/Button.js';
|
||||
import { ProgressBar } from '../components/ui/ProgressBar.js';
|
||||
import { getObjective } from '../lib/api.js';
|
||||
|
||||
function readRouteId(id: string | undefined): number {
|
||||
const parsed = Number(id);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function OKRDetail(): JSX.Element {
|
||||
const id = readRouteId(useParams().id);
|
||||
const { data: objective, isLoading } = useQuery({
|
||||
queryKey: ['objective', id],
|
||||
queryFn: () => getObjective(id),
|
||||
enabled: id > 0,
|
||||
});
|
||||
|
||||
if (isLoading || objective === undefined) {
|
||||
return <div className="rounded-xl border border-gray-200 bg-white p-6 text-gray-500">Loading objective...</div>;
|
||||
}
|
||||
|
||||
const progress = objective.computedProgress ?? 0;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-800">{objective.title}</h1>
|
||||
<p className="mt-2 text-sm text-gray-500">{objective.description}</p>
|
||||
</div>
|
||||
<Badge status={objective.status} />
|
||||
</div>
|
||||
<div className="mb-4 flex gap-3">
|
||||
<Button variant="secondary">REPORT</Button>
|
||||
<Button variant="secondary">OPTIONS</Button>
|
||||
</div>
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex gap-6 text-sm font-medium text-gray-600">
|
||||
<span className="border-b-2 border-blue-600 pb-3 text-blue-600">General Info</span>
|
||||
<span className="pb-3">Conversation</span>
|
||||
<span className="pb-3">Grade & Feedback</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Owner</div>
|
||||
<div className="font-medium text-gray-800">{objective.owner.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Quarter</div>
|
||||
<div className="font-medium text-gray-800">{objective.quarter}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Progress</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<ProgressBar value={progress} />
|
||||
<span className="text-sm font-medium">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-800">Key Results</h2>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{objective.keyResults.map((keyResult) => (
|
||||
<Link key={keyResult.id} to={`/key-results/${keyResult.id}`} className="block py-4 transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{keyResult.title}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Start: {keyResult.startValue} · Target: {keyResult.targetValue} · Deadline:{' '}
|
||||
{new Date(keyResult.deadline).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<ProgressBar value={keyResult.progress} />
|
||||
<div className="mt-1 text-right text-sm text-gray-500">{keyResult.progress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username or email is required'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateProgressSchema = z.object({
|
||||
progress: z.coerce.number().int().min(0, 'Progress cannot be negative').max(100, 'Progress cannot exceed 100'),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpdateProgressFormData = z.infer<typeof updateProgressSchema>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createObjectiveSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
ownerId: z.coerce.number().int().positive('Owner is required'),
|
||||
quarter: z.string().regex(/^Q[1-4]\/\d{4}$/, 'Format must be Q2/2026'),
|
||||
});
|
||||
|
||||
export type CreateObjectiveFormData = z.infer<typeof createObjectiveSchema>;
|
||||
@@ -0,0 +1,59 @@
|
||||
export type Role = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
|
||||
export type ObjectiveStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface KeyResult {
|
||||
id: number;
|
||||
objectiveId: number;
|
||||
title: string;
|
||||
progress: number;
|
||||
startValue: number;
|
||||
targetValue: number;
|
||||
deadline: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Objective {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
ownerId: number;
|
||||
owner: User;
|
||||
quarter: string;
|
||||
status: ObjectiveStatus;
|
||||
keyResults: KeyResult[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
computedProgress?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface CreateObjectivePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
ownerId: number;
|
||||
quarter: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgressPayload {
|
||||
progress: number;
|
||||
comment?: string;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/okr.test.tsx","./src/__tests__/setup.ts","./src/components/layout/applayout.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/progressbar.tsx","./src/hooks/useauth.tsx","./src/hooks/useobjectives.ts","./src/lib/api.ts","./src/lib/queryclient.ts","./src/pages/createobjective.tsx","./src/pages/dashboard.tsx","./src/pages/keyresultdetail.tsx","./src/pages/login.tsx","./src/pages/okrdetail.tsx","./src/schemas/auth.schema.ts","./src/schemas/key-result.schema.ts","./src/schemas/objective.schema.ts","./src/types/okr.types.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
include: ['./src/__tests__/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user