feat: updade workspace

This commit is contained in:
thanhnv
2026-07-11 15:56:31 +09:00
parent 4fc72332f5
commit 193a449829
120 changed files with 868 additions and 350 deletions
+12
View File
@@ -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>
+37
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+30
View File
@@ -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);
});
});
+1
View File
@@ -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>
);
}
+49
View File
@@ -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),
});
}
+11
View File
@@ -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;
}
+54
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
+20
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+91
View File
@@ -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>;
+59
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11
View File
@@ -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;
+21
View File
@@ -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": []
}
+1
View File
@@ -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"}
+15
View File
@@ -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}'],
},
});