@onahhas/hello-dev 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +149 -11
  2. package/backend/Controllers/AccountController.cs +100 -0
  3. package/backend/Controllers/ActivityController.cs +44 -0
  4. package/backend/Controllers/AuthController.cs +127 -0
  5. package/backend/Controllers/LookupController.cs +46 -0
  6. package/backend/Controllers/TasksController.cs +652 -0
  7. package/backend/Controllers/UsersController.cs +181 -0
  8. package/backend/Data/AppDbContext.cs +93 -0
  9. package/backend/Data/DbSeeder.cs +122 -0
  10. package/backend/DevTasks.Api.csproj +13 -0
  11. package/backend/Dtos/ActivityDtos.cs +12 -0
  12. package/backend/Dtos/AuthDtos.cs +37 -0
  13. package/backend/Dtos/TaskDtos.cs +104 -0
  14. package/backend/Dtos/UserDtos.cs +29 -0
  15. package/backend/Enums/EditRequestStatus.cs +8 -0
  16. package/backend/Enums/TaskPriority.cs +8 -0
  17. package/backend/Enums/TaskState.cs +9 -0
  18. package/backend/Enums/TaskVisibility.cs +7 -0
  19. package/backend/Enums/UserRole.cs +7 -0
  20. package/backend/Extensions/ClaimsPrincipalExtensions.cs +23 -0
  21. package/backend/Models/ActivityLog.cs +12 -0
  22. package/backend/Models/AppUser.cs +17 -0
  23. package/backend/Models/TaskEditRequest.cs +31 -0
  24. package/backend/Models/TaskItem.cs +25 -0
  25. package/backend/Program.cs +138 -0
  26. package/backend/Properties/launchSettings.json +13 -0
  27. package/backend/Services/ActivityService.cs +28 -0
  28. package/backend/Services/PasswordHasher.cs +58 -0
  29. package/backend/Services/TokenService.cs +49 -0
  30. package/backend/appsettings.Development.json +10 -0
  31. package/backend/appsettings.json +24 -0
  32. package/frontend/index.html +12 -0
  33. package/frontend/package-lock.json +1769 -0
  34. package/frontend/package.json +23 -0
  35. package/frontend/src/App.tsx +40 -0
  36. package/frontend/src/api/http.ts +75 -0
  37. package/frontend/src/auth/AuthContext.tsx +101 -0
  38. package/frontend/src/components/EditRequestModal.tsx +139 -0
  39. package/frontend/src/components/EditRequestsPanel.tsx +94 -0
  40. package/frontend/src/components/Layout.tsx +76 -0
  41. package/frontend/src/components/PageHeader.tsx +21 -0
  42. package/frontend/src/components/ProtectedRoute.tsx +14 -0
  43. package/frontend/src/components/StatCard.tsx +15 -0
  44. package/frontend/src/components/TaskCard.tsx +83 -0
  45. package/frontend/src/components/TaskDetailsModal.tsx +45 -0
  46. package/frontend/src/components/TaskFilters.tsx +67 -0
  47. package/frontend/src/components/TaskModal.tsx +159 -0
  48. package/frontend/src/components/TaskTable.tsx +68 -0
  49. package/frontend/src/components/UserModal.tsx +124 -0
  50. package/frontend/src/main.tsx +19 -0
  51. package/frontend/src/pages/ActivityPage.tsx +37 -0
  52. package/frontend/src/pages/BoardPage.tsx +75 -0
  53. package/frontend/src/pages/CalendarPage.tsx +101 -0
  54. package/frontend/src/pages/DashboardPage.tsx +131 -0
  55. package/frontend/src/pages/LoginPage.tsx +69 -0
  56. package/frontend/src/pages/ProfilePage.tsx +111 -0
  57. package/frontend/src/pages/PublicTasksPage.tsx +99 -0
  58. package/frontend/src/pages/RegisterPage.tsx +80 -0
  59. package/frontend/src/pages/TasksPage.tsx +135 -0
  60. package/frontend/src/pages/UsersPage.tsx +86 -0
  61. package/frontend/src/styles.css +596 -0
  62. package/frontend/src/theme.tsx +49 -0
  63. package/frontend/src/types.ts +78 -0
  64. package/frontend/src/utils/date.ts +30 -0
  65. package/frontend/src/utils/labels.ts +3 -0
  66. package/frontend/src/vite-env.d.ts +1 -0
  67. package/frontend/tsconfig.json +21 -0
  68. package/frontend/vite.config.ts +15 -0
  69. package/package.json +22 -9
  70. package/index.js +0 -7
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "devtasks-web",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "react-router-dom": "^6.26.2"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^18.3.3",
18
+ "@types/react-dom": "^18.3.0",
19
+ "@vitejs/plugin-react": "^4.3.1",
20
+ "typescript": "^5.6.3",
21
+ "vite": "^5.4.10"
22
+ }
23
+ }
@@ -0,0 +1,40 @@
1
+ import { Navigate, Route, Routes } from 'react-router-dom';
2
+ import { AdminRoute, ProtectedRoute } from './components/ProtectedRoute';
3
+ import { Layout } from './components/Layout';
4
+ import { ActivityPage } from './pages/ActivityPage';
5
+ import { BoardPage } from './pages/BoardPage';
6
+ import { CalendarPage } from './pages/CalendarPage';
7
+ import { DashboardPage } from './pages/DashboardPage';
8
+ import { LoginPage } from './pages/LoginPage';
9
+ import { ProfilePage } from './pages/ProfilePage';
10
+ import { PublicTasksPage } from './pages/PublicTasksPage';
11
+ import { RegisterPage } from './pages/RegisterPage';
12
+ import { TasksPage } from './pages/TasksPage';
13
+ import { UsersPage } from './pages/UsersPage';
14
+
15
+ export default function App() {
16
+ return (
17
+ <Routes>
18
+ <Route path="/login" element={<LoginPage />} />
19
+ <Route path="/register" element={<RegisterPage />} />
20
+
21
+ <Route element={<ProtectedRoute />}>
22
+ <Route element={<Layout />}>
23
+ <Route index element={<DashboardPage />} />
24
+ <Route path="tasks" element={<TasksPage />} />
25
+ <Route path="public" element={<PublicTasksPage />} />
26
+ <Route path="board" element={<BoardPage />} />
27
+ <Route path="calendar" element={<CalendarPage />} />
28
+ <Route path="profile" element={<ProfilePage />} />
29
+
30
+ <Route element={<AdminRoute />}>
31
+ <Route path="users" element={<UsersPage />} />
32
+ <Route path="activity" element={<ActivityPage />} />
33
+ </Route>
34
+ </Route>
35
+ </Route>
36
+
37
+ <Route path="*" element={<Navigate to="/" replace />} />
38
+ </Routes>
39
+ );
40
+ }
@@ -0,0 +1,75 @@
1
+ import type { ApiError } from '../types';
2
+
3
+ const API_BASE = import.meta.env.VITE_API_URL ?? '';
4
+ const AUTH_KEY = 'devtasks_auth';
5
+
6
+ export interface StoredAuth {
7
+ token: string;
8
+ }
9
+
10
+ export function getStoredToken(): string | null {
11
+ const raw = localStorage.getItem(AUTH_KEY);
12
+ if (!raw) return null;
13
+
14
+ try {
15
+ return (JSON.parse(raw) as StoredAuth).token;
16
+ } catch {
17
+ localStorage.removeItem(AUTH_KEY);
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function storeToken(token: string): void {
23
+ localStorage.setItem(AUTH_KEY, JSON.stringify({ token }));
24
+ }
25
+
26
+ export function clearToken(): void {
27
+ localStorage.removeItem(AUTH_KEY);
28
+ }
29
+
30
+ export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
31
+ const token = getStoredToken();
32
+ const headers = new Headers(options.headers);
33
+
34
+ if (!headers.has('Content-Type') && options.body) {
35
+ headers.set('Content-Type', 'application/json');
36
+ }
37
+
38
+ if (token) {
39
+ headers.set('Authorization', `Bearer ${token}`);
40
+ }
41
+
42
+ const response = await fetch(`${API_BASE}${path}`, {
43
+ ...options,
44
+ headers
45
+ });
46
+
47
+ if (response.status === 204) {
48
+ return null as T;
49
+ }
50
+
51
+ const contentType = response.headers.get('content-type') ?? '';
52
+ const data = contentType.includes('application/json')
53
+ ? await response.json()
54
+ : await response.text();
55
+
56
+ if (!response.ok) {
57
+ const err = data as ApiError;
58
+ throw new Error(err.message ?? 'Request failed.');
59
+ }
60
+
61
+ return data as T;
62
+ }
63
+
64
+ export function toQuery(params: object): string {
65
+ const search = new URLSearchParams();
66
+
67
+ Object.entries(params).forEach(([key, rawValue]) => {
68
+ if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
69
+ search.set(key, String(rawValue));
70
+ }
71
+ });
72
+
73
+ const value = search.toString();
74
+ return value ? `?${value}` : '';
75
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ type ReactNode
8
+ } from 'react';
9
+ import { api, clearToken, getStoredToken, storeToken } from '../api/http';
10
+ import type { AuthResponse, RegisterResponse, User } from '../types';
11
+
12
+ interface AuthContextValue {
13
+ user: User | null;
14
+ token: string | null;
15
+ isAdmin: boolean;
16
+ login: (email: string, password: string) => Promise<void>;
17
+ register: (fullName: string, email: string, password: string) => Promise<string>;
18
+ updateStoredUser: (user: User) => void;
19
+ logout: () => void;
20
+ }
21
+
22
+ const USER_KEY = 'devtasks_user';
23
+ const AuthContext = createContext<AuthContextValue | null>(null);
24
+
25
+ function getStoredUser(): User | null {
26
+ const raw = localStorage.getItem(USER_KEY);
27
+ if (!raw) return null;
28
+
29
+ try {
30
+ return JSON.parse(raw) as User;
31
+ } catch {
32
+ localStorage.removeItem(USER_KEY);
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function AuthProvider({ children }: { children: ReactNode }) {
38
+ const [token, setToken] = useState<string | null>(() => getStoredToken());
39
+ const [user, setUser] = useState<User | null>(() => getStoredUser());
40
+
41
+ const updateStoredUser = useCallback((nextUser: User) => {
42
+ localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
43
+ setUser(nextUser);
44
+ }, []);
45
+
46
+ const saveAuth = useCallback((response: AuthResponse) => {
47
+ storeToken(response.token);
48
+ updateStoredUser(response.user);
49
+ setToken(response.token);
50
+ }, [updateStoredUser]);
51
+
52
+ const login = useCallback(async (email: string, password: string) => {
53
+ const response = await api<AuthResponse>('/api/auth/login', {
54
+ method: 'POST',
55
+ body: JSON.stringify({ email, password })
56
+ });
57
+
58
+ saveAuth(response);
59
+ }, [saveAuth]);
60
+
61
+ const register = useCallback(async (
62
+ fullName: string,
63
+ email: string,
64
+ password: string
65
+ ) => {
66
+ const response = await api<RegisterResponse>('/api/auth/register', {
67
+ method: 'POST',
68
+ body: JSON.stringify({ fullName, email, password })
69
+ });
70
+
71
+ return response.message;
72
+ }, []);
73
+
74
+ const logout = useCallback(() => {
75
+ clearToken();
76
+ localStorage.removeItem(USER_KEY);
77
+ setToken(null);
78
+ setUser(null);
79
+ }, []);
80
+
81
+ const value = useMemo<AuthContextValue>(() => ({
82
+ user,
83
+ token,
84
+ isAdmin: user?.role === 'Admin',
85
+ login,
86
+ register,
87
+ updateStoredUser,
88
+ logout
89
+ }), [user, token, login, register, updateStoredUser, logout]);
90
+
91
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
92
+ }
93
+
94
+ export function useAuth(): AuthContextValue {
95
+ const value = useContext(AuthContext);
96
+ if (!value) {
97
+ throw new Error('useAuth must be used inside AuthProvider.');
98
+ }
99
+
100
+ return value;
101
+ }
@@ -0,0 +1,139 @@
1
+ import { useEffect, useState, type FormEvent } from 'react';
2
+ import { api } from '../api/http';
3
+ import type { TaskItem, TaskPriority, TaskStatus, TaskVisibility, User } from '../types';
4
+ import { fromDateInputValue, toDateInputValue } from '../utils/date';
5
+
6
+ interface Props {
7
+ task: TaskItem;
8
+ users: User[];
9
+ onClose: () => void;
10
+ onSaved: () => void;
11
+ }
12
+
13
+ const statusOptions: TaskStatus[] = ['Todo', 'InProgress', 'Done', 'Cancelled'];
14
+ const priorityOptions: TaskPriority[] = ['Low', 'Medium', 'High'];
15
+ const visibilityOptions: TaskVisibility[] = ['Private', 'Public'];
16
+
17
+ export function EditRequestModal({ task, users, onClose, onSaved }: Props) {
18
+ const [title, setTitle] = useState(task.title);
19
+ const [description, setDescription] = useState(task.description);
20
+ const [status, setStatus] = useState<TaskStatus>(task.status);
21
+ const [priority, setPriority] = useState<TaskPriority>(task.priority);
22
+ const [visibility, setVisibility] = useState<TaskVisibility>(task.visibility);
23
+ const [dueDate, setDueDate] = useState(toDateInputValue(task.dueDate));
24
+ const [assignedToUserId, setAssignedToUserId] = useState(task.assignedToUserId ?? '');
25
+ const [error, setError] = useState('');
26
+ const [saving, setSaving] = useState(false);
27
+
28
+ useEffect(() => {
29
+ setTitle(task.title);
30
+ setDescription(task.description);
31
+ setStatus(task.status);
32
+ setPriority(task.priority);
33
+ setVisibility(task.visibility);
34
+ setDueDate(toDateInputValue(task.dueDate));
35
+ setAssignedToUserId(task.assignedToUserId ?? '');
36
+ }, [task]);
37
+
38
+ async function submit(event: FormEvent) {
39
+ event.preventDefault();
40
+ setSaving(true);
41
+ setError('');
42
+
43
+ try {
44
+ await api(`/api/tasks/${task.id}/edit-requests`, {
45
+ method: 'POST',
46
+ body: JSON.stringify({
47
+ title,
48
+ description,
49
+ status,
50
+ priority,
51
+ visibility,
52
+ dueDate: fromDateInputValue(dueDate),
53
+ clearDueDate: dueDate === '',
54
+ assignedToUserId: assignedToUserId || null,
55
+ clearAssignedUser: assignedToUserId === ''
56
+ })
57
+ });
58
+
59
+ onSaved();
60
+ onClose();
61
+ } catch (err) {
62
+ setError(err instanceof Error ? err.message : 'Could not send edit request.');
63
+ } finally {
64
+ setSaving(false);
65
+ }
66
+ }
67
+
68
+ return (
69
+ <div className="modalLayer" role="presentation" onMouseDown={onClose}>
70
+ <form className="modalCard wideModal taskEditModal" onSubmit={submit} onMouseDown={(e) => e.stopPropagation()}>
71
+ <div className="modalHeader">
72
+ <div>
73
+ <span className="eyebrow">Task #{task.id}</span>
74
+ <h2>Request public task edit</h2>
75
+ <p>The owner will accept or reject this request.</p>
76
+ </div>
77
+ <button type="button" className="iconBtn" onClick={onClose}>×</button>
78
+ </div>
79
+
80
+ {error && <div className="errorBox">{error}</div>}
81
+
82
+ <div className="modalBodyGrid">
83
+ <label className="spanTwo">
84
+ Title
85
+ <input value={title} onChange={(e) => setTitle(e.target.value)} required />
86
+ </label>
87
+
88
+ <label className="spanTwo descriptionField">
89
+ Description
90
+ <textarea value={description} onChange={(e) => setDescription(e.target.value)} />
91
+ </label>
92
+
93
+ <label>
94
+ Status
95
+ <select value={status} onChange={(e) => setStatus(e.target.value as TaskStatus)}>
96
+ {statusOptions.map((item) => <option key={item} value={item}>{item}</option>)}
97
+ </select>
98
+ </label>
99
+
100
+ <label>
101
+ Priority
102
+ <select value={priority} onChange={(e) => setPriority(e.target.value as TaskPriority)}>
103
+ {priorityOptions.map((item) => <option key={item} value={item}>{item}</option>)}
104
+ </select>
105
+ </label>
106
+
107
+ <label>
108
+ Visibility
109
+ <select value={visibility} onChange={(e) => setVisibility(e.target.value as TaskVisibility)}>
110
+ {visibilityOptions.map((item) => <option key={item} value={item}>{item}</option>)}
111
+ </select>
112
+ </label>
113
+
114
+ <label>
115
+ Due date
116
+ <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
117
+ </label>
118
+
119
+ <label className="spanTwo">
120
+ Assign to
121
+ <select value={assignedToUserId} onChange={(e) => setAssignedToUserId(e.target.value)}>
122
+ <option value="">Unassigned</option>
123
+ {users.filter((user) => user.isActive).map((user) => (
124
+ <option key={user.id} value={user.id}>{user.fullName}</option>
125
+ ))}
126
+ </select>
127
+ </label>
128
+ </div>
129
+
130
+ <div className="modalActions">
131
+ <button type="button" className="ghostBtn" onClick={onClose}>Cancel</button>
132
+ <button type="submit" className="primaryBtn" disabled={saving}>
133
+ {saving ? 'Sending...' : 'Send request'}
134
+ </button>
135
+ </div>
136
+ </form>
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,94 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { api } from '../api/http';
3
+ import type { TaskEditRequest } from '../types';
4
+ import { formatDate, formatDateTime } from '../utils/date';
5
+ import { label } from '../utils/labels';
6
+
7
+ interface Props {
8
+ refreshKey?: number;
9
+ onChanged?: () => void;
10
+ }
11
+
12
+ export function EditRequestsPanel({ refreshKey = 0, onChanged }: Props) {
13
+ const [requests, setRequests] = useState<TaskEditRequest[]>([]);
14
+ const [error, setError] = useState('');
15
+
16
+ useEffect(() => {
17
+ void load();
18
+ }, [refreshKey]);
19
+
20
+ async function load() {
21
+ try {
22
+ setRequests(await api<TaskEditRequest[]>('/api/tasks/edit-requests/inbox'));
23
+ } catch (err) {
24
+ setError(err instanceof Error ? err.message : 'Could not load edit requests.');
25
+ }
26
+ }
27
+
28
+ async function resolve(request: TaskEditRequest, action: 'accept' | 'reject') {
29
+ const note = action === 'reject'
30
+ ? window.prompt('Optional reject note:', '') ?? ''
31
+ : '';
32
+
33
+ try {
34
+ await api<TaskEditRequest>(`/api/tasks/edit-requests/${request.id}/${action}`, {
35
+ method: 'POST',
36
+ body: JSON.stringify({ ownerNote: note })
37
+ });
38
+
39
+ await load();
40
+ onChanged?.();
41
+ } catch (err) {
42
+ alert(err instanceof Error ? err.message : `Could not ${action} request.`);
43
+ }
44
+ }
45
+
46
+ const pending = useMemo(() => requests.filter((item) => item.statusOfRequest === 'Pending'), [requests]);
47
+
48
+ return (
49
+ <section className="panelCard requestPanel">
50
+ <div className="sectionTitle">
51
+ <div>
52
+ <span className="eyebrow">Collaboration</span>
53
+ <h2>Pending edit requests</h2>
54
+ </div>
55
+ <span className="countBadge">{pending.length}</span>
56
+ </div>
57
+
58
+ {error && <div className="errorBox">{error}</div>}
59
+
60
+ {pending.length === 0 && <p className="emptyText">No pending requests for your tasks.</p>}
61
+
62
+ <div className="requestList">
63
+ {pending.map((request) => (
64
+ <article key={request.id} className="requestItem">
65
+ <div>
66
+ <strong>Task #{request.taskId}: {request.taskTitle}</strong>
67
+ <span>{request.requestedByFullName} requested changes · {formatDateTime(request.createdAt)}</span>
68
+ </div>
69
+
70
+ <div className="changeGrid">
71
+ {request.title && <span>Title <strong>{request.title}</strong></span>}
72
+ {request.status && <span>Status <strong>{label(request.status)}</strong></span>}
73
+ {request.priority && <span>Priority <strong>{request.priority}</strong></span>}
74
+ {request.visibility && <span>Visibility <strong>{request.visibility}</strong></span>}
75
+ {(request.dueDate || request.clearDueDate) && (
76
+ <span>Due <strong>{request.clearDueDate ? 'Clear due date' : formatDate(request.dueDate)}</strong></span>
77
+ )}
78
+ {(request.assignedToFullName || request.clearAssignedUser) && (
79
+ <span>Assign <strong>{request.clearAssignedUser ? 'Unassigned' : request.assignedToFullName}</strong></span>
80
+ )}
81
+ </div>
82
+
83
+ {request.description && <p className="requestDescription">{request.description}</p>}
84
+
85
+ <div className="cardActions">
86
+ <button className="primaryBtn" type="button" onClick={() => void resolve(request, 'accept')}>Accept</button>
87
+ <button className="dangerBtn" type="button" onClick={() => void resolve(request, 'reject')}>Reject</button>
88
+ </div>
89
+ </article>
90
+ ))}
91
+ </div>
92
+ </section>
93
+ );
94
+ }
@@ -0,0 +1,76 @@
1
+ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
2
+ import { useAuth } from '../auth/AuthContext';
3
+ import { useTheme } from '../theme';
4
+
5
+ const normalLinks = [
6
+ { to: '/', text: 'Overview', icon: '◎' },
7
+ { to: '/tasks', text: 'My Tasks', icon: '◫' },
8
+ { to: '/public', text: 'Public', icon: '◉' },
9
+ { to: '/board', text: 'Board', icon: '▦' },
10
+ { to: '/calendar', text: 'Calendar', icon: '◌' }
11
+ ];
12
+
13
+ export function Layout() {
14
+ const { user, logout, isAdmin } = useAuth();
15
+ const { theme, toggleTheme } = useTheme();
16
+ const navigate = useNavigate();
17
+
18
+ function handleLogout() {
19
+ logout();
20
+ navigate('/login');
21
+ }
22
+
23
+ return (
24
+ <div className="appShell">
25
+ <aside className="sidebar">
26
+ <div className="brandBlock">
27
+ <div className="brandMark">D</div>
28
+ <div>
29
+ <strong>DevTasks</strong>
30
+ <span>Focused task control</span>
31
+ </div>
32
+ </div>
33
+
34
+ <nav className="navList">
35
+ {normalLinks.map((link) => (
36
+ <NavLink key={link.to} to={link.to} end={link.to === '/'}>
37
+ <span>{link.icon}</span>
38
+ {link.text}
39
+ </NavLink>
40
+ ))}
41
+
42
+ {isAdmin && (
43
+ <>
44
+ <NavLink to="/users"><span>☷</span>Users</NavLink>
45
+ <NavLink to="/activity"><span>⌁</span>Activity</NavLink>
46
+ </>
47
+ )}
48
+ </nav>
49
+
50
+ <div className="themeSwitch">
51
+ <span>{theme === 'dark' ? 'Dark mode' : 'Light mode'}</span>
52
+ <button className="ghostBtn" type="button" onClick={toggleTheme}>
53
+ {theme === 'dark' ? 'Light' : 'Dark'}
54
+ </button>
55
+ </div>
56
+
57
+ <div className="sideFooter">
58
+ <NavLink to="/profile" className="profileMini">
59
+ <span className="avatar">{user?.fullName.slice(0, 1).toUpperCase()}</span>
60
+ <span>
61
+ <strong>{user?.fullName}</strong>
62
+ <small>{user?.role}</small>
63
+ </span>
64
+ </NavLink>
65
+ <button className="ghostBtn full" type="button" onClick={handleLogout}>
66
+ Logout
67
+ </button>
68
+ </div>
69
+ </aside>
70
+
71
+ <main className="mainPanel">
72
+ <Outlet />
73
+ </main>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ interface Props {
4
+ eyebrow?: string;
5
+ title: string;
6
+ description?: string;
7
+ actions?: ReactNode;
8
+ }
9
+
10
+ export function PageHeader({ eyebrow, title, description, actions }: Props) {
11
+ return (
12
+ <header className="pageHeader">
13
+ <div>
14
+ {eyebrow && <span className="eyebrow">{eyebrow}</span>}
15
+ <h1>{title}</h1>
16
+ {description && <p>{description}</p>}
17
+ </div>
18
+ {actions && <div className="headerActions">{actions}</div>}
19
+ </header>
20
+ );
21
+ }
@@ -0,0 +1,14 @@
1
+ import { Navigate, Outlet } from 'react-router-dom';
2
+ import { useAuth } from '../auth/AuthContext';
3
+
4
+ export function ProtectedRoute() {
5
+ const { token } = useAuth();
6
+ return token ? <Outlet /> : <Navigate to="/login" replace />;
7
+ }
8
+
9
+ export function AdminRoute() {
10
+ const { token, isAdmin } = useAuth();
11
+
12
+ if (!token) return <Navigate to="/login" replace />;
13
+ return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
14
+ }
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ label: string;
3
+ value: number | string;
4
+ hint?: string;
5
+ }
6
+
7
+ export function StatCard({ label, value, hint }: Props) {
8
+ return (
9
+ <article className="statCard">
10
+ <span>{label}</span>
11
+ <strong>{value}</strong>
12
+ {hint && <small>{hint}</small>}
13
+ </article>
14
+ );
15
+ }
@@ -0,0 +1,83 @@
1
+ import { useAuth } from '../auth/AuthContext';
2
+ import type { TaskItem } from '../types';
3
+ import { formatDate } from '../utils/date';
4
+ import { label } from '../utils/labels';
5
+
6
+ interface Props {
7
+ task: TaskItem;
8
+ onView?: (task: TaskItem) => void;
9
+ onEdit?: (task: TaskItem) => void;
10
+ onDelete?: (task: TaskItem) => void;
11
+ onRequestEdit?: (task: TaskItem) => void;
12
+ draggable?: boolean;
13
+ onDragStart?: (task: TaskItem) => void;
14
+ }
15
+
16
+ export function TaskCard({
17
+ task,
18
+ onView,
19
+ onEdit,
20
+ onDelete,
21
+ onRequestEdit,
22
+ draggable,
23
+ onDragStart
24
+ }: Props) {
25
+ const { user, isAdmin } = useAuth();
26
+ const canWrite = isAdmin || task.createdByUserId === user?.id;
27
+ const canRequestEdit = !canWrite && task.visibility === 'Public';
28
+
29
+ return (
30
+ <article
31
+ className={`taskCard priority${task.priority} ${task.isOverdue ? 'overdue' : ''}`}
32
+ draggable={draggable}
33
+ onDragStart={() => onDragStart?.(task)}
34
+ >
35
+ <div className="taskTopLine">
36
+ <span className="taskNumber">#{task.id}</span>
37
+ <span className={`statusPill ${task.status}`}>{label(task.status)}</span>
38
+ <span className="tinyText">{task.visibility}</span>
39
+ </div>
40
+
41
+ <h3>{task.title}</h3>
42
+ {task.description && <p>{task.description}</p>}
43
+
44
+ <div className="taskMeta">
45
+ <span>Priority: {task.priority}</span>
46
+ <span>Due: {formatDate(task.dueDate)}</span>
47
+ <span>By: {task.createdByFullName}</span>
48
+ <span>To: {task.assignedToFullName ?? 'Unassigned'}</span>
49
+ </div>
50
+
51
+ {task.pendingEditRequestCount > 0 && (
52
+ <div className="requestHint">
53
+ {task.pendingEditRequestCount} pending edit request{task.pendingEditRequestCount === 1 ? '' : 's'}
54
+ </div>
55
+ )}
56
+
57
+ {(onView || canWrite || canRequestEdit) && (
58
+ <div className="cardActions">
59
+ {onView && (
60
+ <button type="button" className="ghostBtn" onClick={() => onView(task)}>
61
+ View
62
+ </button>
63
+ )}
64
+ {canWrite && onEdit && (
65
+ <button type="button" className="ghostBtn" onClick={() => onEdit(task)}>
66
+ Edit
67
+ </button>
68
+ )}
69
+ {canRequestEdit && onRequestEdit && (
70
+ <button type="button" className="ghostBtn" onClick={() => onRequestEdit(task)}>
71
+ Request edit
72
+ </button>
73
+ )}
74
+ {canWrite && onDelete && (
75
+ <button type="button" className="dangerBtn" onClick={() => onDelete(task)}>
76
+ Delete
77
+ </button>
78
+ )}
79
+ </div>
80
+ )}
81
+ </article>
82
+ );
83
+ }