@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.
- package/README.md +149 -11
- package/backend/Controllers/AccountController.cs +100 -0
- package/backend/Controllers/ActivityController.cs +44 -0
- package/backend/Controllers/AuthController.cs +127 -0
- package/backend/Controllers/LookupController.cs +46 -0
- package/backend/Controllers/TasksController.cs +652 -0
- package/backend/Controllers/UsersController.cs +181 -0
- package/backend/Data/AppDbContext.cs +93 -0
- package/backend/Data/DbSeeder.cs +122 -0
- package/backend/DevTasks.Api.csproj +13 -0
- package/backend/Dtos/ActivityDtos.cs +12 -0
- package/backend/Dtos/AuthDtos.cs +37 -0
- package/backend/Dtos/TaskDtos.cs +104 -0
- package/backend/Dtos/UserDtos.cs +29 -0
- package/backend/Enums/EditRequestStatus.cs +8 -0
- package/backend/Enums/TaskPriority.cs +8 -0
- package/backend/Enums/TaskState.cs +9 -0
- package/backend/Enums/TaskVisibility.cs +7 -0
- package/backend/Enums/UserRole.cs +7 -0
- package/backend/Extensions/ClaimsPrincipalExtensions.cs +23 -0
- package/backend/Models/ActivityLog.cs +12 -0
- package/backend/Models/AppUser.cs +17 -0
- package/backend/Models/TaskEditRequest.cs +31 -0
- package/backend/Models/TaskItem.cs +25 -0
- package/backend/Program.cs +138 -0
- package/backend/Properties/launchSettings.json +13 -0
- package/backend/Services/ActivityService.cs +28 -0
- package/backend/Services/PasswordHasher.cs +58 -0
- package/backend/Services/TokenService.cs +49 -0
- package/backend/appsettings.Development.json +10 -0
- package/backend/appsettings.json +24 -0
- package/frontend/index.html +12 -0
- package/frontend/package-lock.json +1769 -0
- package/frontend/package.json +23 -0
- package/frontend/src/App.tsx +40 -0
- package/frontend/src/api/http.ts +75 -0
- package/frontend/src/auth/AuthContext.tsx +101 -0
- package/frontend/src/components/EditRequestModal.tsx +139 -0
- package/frontend/src/components/EditRequestsPanel.tsx +94 -0
- package/frontend/src/components/Layout.tsx +76 -0
- package/frontend/src/components/PageHeader.tsx +21 -0
- package/frontend/src/components/ProtectedRoute.tsx +14 -0
- package/frontend/src/components/StatCard.tsx +15 -0
- package/frontend/src/components/TaskCard.tsx +83 -0
- package/frontend/src/components/TaskDetailsModal.tsx +45 -0
- package/frontend/src/components/TaskFilters.tsx +67 -0
- package/frontend/src/components/TaskModal.tsx +159 -0
- package/frontend/src/components/TaskTable.tsx +68 -0
- package/frontend/src/components/UserModal.tsx +124 -0
- package/frontend/src/main.tsx +19 -0
- package/frontend/src/pages/ActivityPage.tsx +37 -0
- package/frontend/src/pages/BoardPage.tsx +75 -0
- package/frontend/src/pages/CalendarPage.tsx +101 -0
- package/frontend/src/pages/DashboardPage.tsx +131 -0
- package/frontend/src/pages/LoginPage.tsx +69 -0
- package/frontend/src/pages/ProfilePage.tsx +111 -0
- package/frontend/src/pages/PublicTasksPage.tsx +99 -0
- package/frontend/src/pages/RegisterPage.tsx +80 -0
- package/frontend/src/pages/TasksPage.tsx +135 -0
- package/frontend/src/pages/UsersPage.tsx +86 -0
- package/frontend/src/styles.css +596 -0
- package/frontend/src/theme.tsx +49 -0
- package/frontend/src/types.ts +78 -0
- package/frontend/src/utils/date.ts +30 -0
- package/frontend/src/utils/labels.ts +3 -0
- package/frontend/src/vite-env.d.ts +1 -0
- package/frontend/tsconfig.json +21 -0
- package/frontend/vite.config.ts +15 -0
- package/package.json +22 -9
- 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
|
+
}
|