@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,101 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { api } from '../api/http';
|
|
3
|
+
import { useAuth } from '../auth/AuthContext';
|
|
4
|
+
import { PageHeader } from '../components/PageHeader';
|
|
5
|
+
import { TaskDetailsModal } from '../components/TaskDetailsModal';
|
|
6
|
+
import type { TaskItem } from '../types';
|
|
7
|
+
|
|
8
|
+
function startOfMonth(date: Date) {
|
|
9
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sameDay(a: Date, b: Date) {
|
|
13
|
+
return a.getFullYear() === b.getFullYear() &&
|
|
14
|
+
a.getMonth() === b.getMonth() &&
|
|
15
|
+
a.getDate() === b.getDate();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CalendarPage() {
|
|
19
|
+
const { isAdmin } = useAuth();
|
|
20
|
+
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
|
21
|
+
const [cursor, setCursor] = useState(() => startOfMonth(new Date()));
|
|
22
|
+
const [viewingTask, setViewingTask] = useState<TaskItem | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
void api<TaskItem[]>(isAdmin ? '/api/tasks/all' : '/api/tasks/my').then(setTasks);
|
|
26
|
+
}, [isAdmin]);
|
|
27
|
+
|
|
28
|
+
const days = useMemo(() => {
|
|
29
|
+
const start = startOfMonth(cursor);
|
|
30
|
+
const firstDay = start.getDay();
|
|
31
|
+
const gridStart = new Date(start);
|
|
32
|
+
gridStart.setDate(start.getDate() - firstDay);
|
|
33
|
+
|
|
34
|
+
return Array.from({ length: 42 }, (_, index) => {
|
|
35
|
+
const day = new Date(gridStart);
|
|
36
|
+
day.setDate(gridStart.getDate() + index);
|
|
37
|
+
return day;
|
|
38
|
+
});
|
|
39
|
+
}, [cursor]);
|
|
40
|
+
|
|
41
|
+
function monthTitle() {
|
|
42
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
43
|
+
month: 'long',
|
|
44
|
+
year: 'numeric'
|
|
45
|
+
}).format(cursor);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function addMonths(value: number) {
|
|
49
|
+
setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + value, 1));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section>
|
|
54
|
+
<PageHeader
|
|
55
|
+
eyebrow="Schedule"
|
|
56
|
+
title="Calendar"
|
|
57
|
+
description={isAdmin ? 'Admin view shows due dates for all users.' : 'A monthly view based on your task due dates.'}
|
|
58
|
+
actions={(
|
|
59
|
+
<div className="buttonGroup">
|
|
60
|
+
<button className="ghostBtn" onClick={() => addMonths(-1)}>Previous</button>
|
|
61
|
+
<button className="ghostBtn" onClick={() => setCursor(startOfMonth(new Date()))}>Today</button>
|
|
62
|
+
<button className="ghostBtn" onClick={() => addMonths(1)}>Next</button>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<section className="calendarCard">
|
|
68
|
+
<div className="calendarTitle">{monthTitle()}</div>
|
|
69
|
+
<div className="calendarWeekdays">
|
|
70
|
+
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => <span key={day}>{day}</span>)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="calendarGrid">
|
|
74
|
+
{days.map((day) => {
|
|
75
|
+
const dayTasks = tasks.filter((task) => task.dueDate && sameDay(new Date(task.dueDate), day));
|
|
76
|
+
const isMuted = day.getMonth() !== cursor.getMonth();
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<article key={day.toISOString()} className={`calendarCell ${isMuted ? 'mutedDay' : ''}`}>
|
|
80
|
+
<strong>{day.getDate()}</strong>
|
|
81
|
+
{dayTasks.slice(0, 3).map((task) => (
|
|
82
|
+
<button
|
|
83
|
+
key={task.id}
|
|
84
|
+
type="button"
|
|
85
|
+
className={`calendarTask ${task.priority}`}
|
|
86
|
+
onClick={() => setViewingTask(task)}
|
|
87
|
+
>
|
|
88
|
+
#{task.id} {task.title}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
{dayTasks.length > 3 && <small>+{dayTasks.length - 3} more</small>}
|
|
92
|
+
</article>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
{viewingTask && <TaskDetailsModal task={viewingTask} onClose={() => setViewingTask(null)} />}
|
|
99
|
+
</section>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { api } from '../api/http';
|
|
4
|
+
import { useAuth } from '../auth/AuthContext';
|
|
5
|
+
import { PageHeader } from '../components/PageHeader';
|
|
6
|
+
import { StatCard } from '../components/StatCard';
|
|
7
|
+
import { TaskCard } from '../components/TaskCard';
|
|
8
|
+
import { TaskDetailsModal } from '../components/TaskDetailsModal';
|
|
9
|
+
import type { ActivityLog, TaskItem, User } from '../types';
|
|
10
|
+
import { formatDateTime } from '../utils/date';
|
|
11
|
+
|
|
12
|
+
export function DashboardPage() {
|
|
13
|
+
const { isAdmin } = useAuth();
|
|
14
|
+
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
|
15
|
+
const [publicTasks, setPublicTasks] = useState<TaskItem[]>([]);
|
|
16
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
17
|
+
const [activity, setActivity] = useState<ActivityLog[]>([]);
|
|
18
|
+
const [viewingTask, setViewingTask] = useState<TaskItem | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
void load();
|
|
22
|
+
}, [isAdmin]);
|
|
23
|
+
|
|
24
|
+
async function load() {
|
|
25
|
+
const [mainTasks, visibleTasks] = await Promise.all([
|
|
26
|
+
api<TaskItem[]>(isAdmin ? '/api/tasks/all' : '/api/tasks/my'),
|
|
27
|
+
api<TaskItem[]>('/api/tasks/public')
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
setTasks(mainTasks);
|
|
31
|
+
setPublicTasks(visibleTasks);
|
|
32
|
+
|
|
33
|
+
if (isAdmin) {
|
|
34
|
+
const [allUsers, logs] = await Promise.all([
|
|
35
|
+
api<User[]>('/api/users'),
|
|
36
|
+
api<ActivityLog[]>('/api/activity')
|
|
37
|
+
]);
|
|
38
|
+
setUsers(allUsers);
|
|
39
|
+
setActivity(logs);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stats = useMemo(() => {
|
|
44
|
+
const completed = tasks.filter((task) => task.status === 'Done').length;
|
|
45
|
+
const overdue = tasks.filter((task) => task.isOverdue).length;
|
|
46
|
+
const inProgress = tasks.filter((task) => task.status === 'InProgress').length;
|
|
47
|
+
const pendingRequests = tasks.reduce((total, task) => total + task.pendingEditRequestCount, 0);
|
|
48
|
+
|
|
49
|
+
return { completed, overdue, inProgress, pendingRequests };
|
|
50
|
+
}, [tasks]);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section>
|
|
54
|
+
<PageHeader
|
|
55
|
+
eyebrow="Overview"
|
|
56
|
+
title="Dashboard"
|
|
57
|
+
description={isAdmin ? 'System-wide work, users, and activity.' : 'Your work, shared tasks, and quick actions.'}
|
|
58
|
+
actions={<Link className="primaryBtn" to="/tasks">View all tasks</Link>}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div className="heroBoard">
|
|
62
|
+
<div>
|
|
63
|
+
<span className="eyebrow">Focus</span>
|
|
64
|
+
<h2>{tasks.length} tracked task{tasks.length === 1 ? '' : 's'}</h2>
|
|
65
|
+
<p>Use the table for fast scanning, the board for movement, and edit requests for public collaboration.</p>
|
|
66
|
+
</div>
|
|
67
|
+
<Link className="ghostBtn" to="/public">Browse public tasks</Link>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="statsGrid">
|
|
71
|
+
<StatCard label={isAdmin ? 'All tasks' : 'My tasks'} value={tasks.length} hint="Listed by numeric ID" />
|
|
72
|
+
<StatCard label="In progress" value={stats.inProgress} hint="Currently active" />
|
|
73
|
+
<StatCard label="Completed" value={stats.completed} hint="Done tasks" />
|
|
74
|
+
<StatCard label="Overdue" value={stats.overdue} hint="Needs attention" />
|
|
75
|
+
<StatCard label="Public tasks" value={publicTasks.length} hint="Visible to all users" />
|
|
76
|
+
<StatCard label="Edit requests" value={stats.pendingRequests} hint="Waiting for owners" />
|
|
77
|
+
{isAdmin && <StatCard label="Users" value={users.length} hint="Admin managed accounts" />}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="twoColumns">
|
|
81
|
+
<section className="panelCard">
|
|
82
|
+
<div className="sectionTitle">
|
|
83
|
+
<div>
|
|
84
|
+
<span className="eyebrow">Work</span>
|
|
85
|
+
<h2>Recent tasks</h2>
|
|
86
|
+
</div>
|
|
87
|
+
<Link to="/board">View Board</Link>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="taskList compactList">
|
|
91
|
+
{tasks.slice(0, 4).map((task) => (
|
|
92
|
+
<TaskCard key={task.id} task={task} onView={setViewingTask} />
|
|
93
|
+
))}
|
|
94
|
+
{tasks.length === 0 && <p className="emptyText">No tasks yet.</p>}
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
<section className="panelCard">
|
|
99
|
+
<div className="sectionTitle">
|
|
100
|
+
<div>
|
|
101
|
+
<span className="eyebrow">Timeline</span>
|
|
102
|
+
<h2>{isAdmin ? 'Recent activity' : 'Quick links'}</h2>
|
|
103
|
+
</div>
|
|
104
|
+
<Link to={isAdmin ? '/activity' : '/calendar'}>
|
|
105
|
+
{isAdmin ? 'View all activity' : 'View Calendar'}
|
|
106
|
+
</Link>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{isAdmin ? (
|
|
110
|
+
<div className="activityList">
|
|
111
|
+
{activity.slice(0, 7).map((log) => (
|
|
112
|
+
<article key={log.id}>
|
|
113
|
+
<strong>{log.action}</strong>
|
|
114
|
+
<span>{log.userFullName} · {formatDateTime(log.createdAt)}</span>
|
|
115
|
+
</article>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
<div className="quickLinks">
|
|
120
|
+
<Link to="/calendar">Open Calendar</Link>
|
|
121
|
+
<Link to="/public">Browse Public Tasks</Link>
|
|
122
|
+
<Link to="/board">Open Board</Link>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</section>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{viewingTask && <TaskDetailsModal task={viewingTask} onClose={() => setViewingTask(null)} />}
|
|
129
|
+
</section>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, type FormEvent } from 'react';
|
|
2
|
+
import { Link, useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../auth/AuthContext';
|
|
4
|
+
import { useTheme } from '../theme';
|
|
5
|
+
|
|
6
|
+
export function LoginPage() {
|
|
7
|
+
const { login } = useAuth();
|
|
8
|
+
const { theme, toggleTheme } = useTheme();
|
|
9
|
+
const navigate = useNavigate();
|
|
10
|
+
const [email, setEmail] = useState('admin@devtasks.local');
|
|
11
|
+
const [password, setPassword] = useState('Admin123!');
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function submit(event: FormEvent) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
setError('');
|
|
18
|
+
setLoading(true);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await login(email, password);
|
|
22
|
+
navigate('/');
|
|
23
|
+
} catch (err) {
|
|
24
|
+
setError(err instanceof Error ? err.message : 'Login failed.');
|
|
25
|
+
} finally {
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<main className="authPage">
|
|
32
|
+
<section className="authVisual">
|
|
33
|
+
<button className="authThemeBtn" type="button" onClick={toggleTheme}>{theme === 'dark' ? 'Light' : 'Dark'} mode</button>
|
|
34
|
+
<div className="authBadge">Minimal task control</div>
|
|
35
|
+
<h1>Ship work through lists, boards, and reviews.</h1>
|
|
36
|
+
<p>
|
|
37
|
+
Track private work, public team tasks, edit requests, due dates,
|
|
38
|
+
and admin activity in one clean interface.
|
|
39
|
+
</p>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<form className="authCard" onSubmit={submit}>
|
|
43
|
+
<span className="eyebrow">Welcome back</span>
|
|
44
|
+
<h2>Login</h2>
|
|
45
|
+
<p>Use the seeded admin account or login after admin activation.</p>
|
|
46
|
+
|
|
47
|
+
{error && <div className="errorBox">{error}</div>}
|
|
48
|
+
|
|
49
|
+
<label>
|
|
50
|
+
Email
|
|
51
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
|
|
52
|
+
</label>
|
|
53
|
+
|
|
54
|
+
<label>
|
|
55
|
+
Password
|
|
56
|
+
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
|
|
57
|
+
</label>
|
|
58
|
+
|
|
59
|
+
<button className="primaryBtn full" disabled={loading} type="submit">
|
|
60
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
<small>
|
|
64
|
+
No account? <Link to="/register">Create one</Link>
|
|
65
|
+
</small>
|
|
66
|
+
</form>
|
|
67
|
+
</main>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import { api } from '../api/http';
|
|
3
|
+
import { useAuth } from '../auth/AuthContext';
|
|
4
|
+
import { PageHeader } from '../components/PageHeader';
|
|
5
|
+
import type { User } from '../types';
|
|
6
|
+
import { formatDate } from '../utils/date';
|
|
7
|
+
|
|
8
|
+
export function ProfilePage() {
|
|
9
|
+
const { user, updateStoredUser } = useAuth();
|
|
10
|
+
const [fullName, setFullName] = useState(user?.fullName ?? '');
|
|
11
|
+
const [email, setEmail] = useState(user?.email ?? '');
|
|
12
|
+
const [currentPassword, setCurrentPassword] = useState('');
|
|
13
|
+
const [newPassword, setNewPassword] = useState('');
|
|
14
|
+
const [message, setMessage] = useState('');
|
|
15
|
+
const [error, setError] = useState('');
|
|
16
|
+
const [saving, setSaving] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setFullName(user?.fullName ?? '');
|
|
20
|
+
setEmail(user?.email ?? '');
|
|
21
|
+
}, [user]);
|
|
22
|
+
|
|
23
|
+
async function submit(event: FormEvent) {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
setSaving(true);
|
|
26
|
+
setMessage('');
|
|
27
|
+
setError('');
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const updated = await api<User>('/api/account/me', {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
fullName,
|
|
34
|
+
email,
|
|
35
|
+
currentPassword: currentPassword || undefined,
|
|
36
|
+
newPassword: newPassword || undefined
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
updateStoredUser(updated);
|
|
41
|
+
setCurrentPassword('');
|
|
42
|
+
setNewPassword('');
|
|
43
|
+
setMessage('Profile updated.');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(err instanceof Error ? err.message : 'Could not update profile.');
|
|
46
|
+
} finally {
|
|
47
|
+
setSaving(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section>
|
|
53
|
+
<PageHeader
|
|
54
|
+
eyebrow="Account"
|
|
55
|
+
title="Profile"
|
|
56
|
+
description="View and edit your account details."
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<section className="profileCardLarge">
|
|
60
|
+
<span className="profileAvatarLarge">{user?.fullName.slice(0, 1).toUpperCase()}</span>
|
|
61
|
+
<div>
|
|
62
|
+
<h2>{user?.fullName}</h2>
|
|
63
|
+
<p>{user?.email}</p>
|
|
64
|
+
<div className="profileFields">
|
|
65
|
+
<span>Role: <strong>{user?.role}</strong></span>
|
|
66
|
+
<span>Status: <strong>{user?.isActive ? 'Active' : 'Inactive'}</strong></span>
|
|
67
|
+
<span>Created: <strong>{user ? formatDate(user.createdAt) : '-'}</strong></span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<form className="panelCard profileForm" onSubmit={submit}>
|
|
73
|
+
<div className="sectionTitle">
|
|
74
|
+
<div>
|
|
75
|
+
<span className="eyebrow">Edit</span>
|
|
76
|
+
<h2>Account settings</h2>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{message && <div className="successBox">{message}</div>}
|
|
81
|
+
{error && <div className="errorBox">{error}</div>}
|
|
82
|
+
|
|
83
|
+
<div className="formGrid">
|
|
84
|
+
<label>
|
|
85
|
+
Full name
|
|
86
|
+
<input value={fullName} onChange={(e) => setFullName(e.target.value)} required />
|
|
87
|
+
</label>
|
|
88
|
+
|
|
89
|
+
<label>
|
|
90
|
+
Email
|
|
91
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
|
92
|
+
</label>
|
|
93
|
+
|
|
94
|
+
<label>
|
|
95
|
+
Current password
|
|
96
|
+
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
<label>
|
|
100
|
+
New password
|
|
101
|
+
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
|
102
|
+
</label>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="modalActions">
|
|
106
|
+
<button className="primaryBtn" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save profile'}</button>
|
|
107
|
+
</div>
|
|
108
|
+
</form>
|
|
109
|
+
</section>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { api, toQuery } from '../api/http';
|
|
3
|
+
import { EditRequestModal } from '../components/EditRequestModal';
|
|
4
|
+
import { PageHeader } from '../components/PageHeader';
|
|
5
|
+
import { TaskCard } from '../components/TaskCard';
|
|
6
|
+
import { TaskDetailsModal } from '../components/TaskDetailsModal';
|
|
7
|
+
import { TaskFilters, type TaskFiltersValue } from '../components/TaskFilters';
|
|
8
|
+
import { TaskTable } from '../components/TaskTable';
|
|
9
|
+
import type { TaskItem, User } from '../types';
|
|
10
|
+
|
|
11
|
+
const defaultFilters: TaskFiltersValue = {
|
|
12
|
+
q: '',
|
|
13
|
+
status: '',
|
|
14
|
+
priority: '',
|
|
15
|
+
visibility: 'Public'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function PublicTasksPage() {
|
|
19
|
+
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
|
20
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
21
|
+
const [filters, setFilters] = useState(defaultFilters);
|
|
22
|
+
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
|
23
|
+
const [viewingTask, setViewingTask] = useState<TaskItem | null>(null);
|
|
24
|
+
const [requestingTask, setRequestingTask] = useState<TaskItem | null>(null);
|
|
25
|
+
const [message, setMessage] = useState('');
|
|
26
|
+
const query = useMemo(() => toQuery({
|
|
27
|
+
q: filters.q,
|
|
28
|
+
status: filters.status,
|
|
29
|
+
priority: filters.priority
|
|
30
|
+
}), [filters]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
void loadTasks();
|
|
34
|
+
}, [query]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
void api<User[]>('/api/lookup/users').then(setUsers).catch(() => setUsers([]));
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
async function loadTasks() {
|
|
41
|
+
setTasks(await api<TaskItem[]>(`/api/tasks/public${query}`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function afterRequestSaved() {
|
|
45
|
+
setMessage('Edit request sent. The task owner can accept or reject it from My Tasks.');
|
|
46
|
+
void loadTasks();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section>
|
|
51
|
+
<PageHeader
|
|
52
|
+
eyebrow="Shared"
|
|
53
|
+
title="Public Tasks"
|
|
54
|
+
description="View shared tasks and request edits without overwriting the owner’s work."
|
|
55
|
+
actions={(
|
|
56
|
+
<button className="ghostBtn" type="button" onClick={() => setViewMode(viewMode === 'table' ? 'cards' : 'table')}>
|
|
57
|
+
{viewMode === 'table' ? 'Card view' : 'Table view'}
|
|
58
|
+
</button>
|
|
59
|
+
)}
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<TaskFilters value={filters} onChange={setFilters} showVisibility={false} />
|
|
63
|
+
{message && <div className="successBox">{message}</div>}
|
|
64
|
+
|
|
65
|
+
{viewMode === 'table' ? (
|
|
66
|
+
<>
|
|
67
|
+
<TaskTable
|
|
68
|
+
tasks={tasks}
|
|
69
|
+
onView={setViewingTask}
|
|
70
|
+
onRequestEdit={setRequestingTask}
|
|
71
|
+
/>
|
|
72
|
+
{tasks.length === 0 && <div className="emptyState">No public tasks found.</div>}
|
|
73
|
+
</>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="taskList">
|
|
76
|
+
{tasks.map((task) => (
|
|
77
|
+
<TaskCard
|
|
78
|
+
key={task.id}
|
|
79
|
+
task={task}
|
|
80
|
+
onView={setViewingTask}
|
|
81
|
+
onRequestEdit={setRequestingTask}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
{tasks.length === 0 && <div className="emptyState">No public tasks found.</div>}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
{viewingTask && <TaskDetailsModal task={viewingTask} onClose={() => setViewingTask(null)} />}
|
|
89
|
+
{requestingTask && (
|
|
90
|
+
<EditRequestModal
|
|
91
|
+
task={requestingTask}
|
|
92
|
+
users={users}
|
|
93
|
+
onClose={() => setRequestingTask(null)}
|
|
94
|
+
onSaved={afterRequestSaved}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState, type FormEvent } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../auth/AuthContext';
|
|
4
|
+
import { useTheme } from '../theme';
|
|
5
|
+
|
|
6
|
+
export function RegisterPage() {
|
|
7
|
+
const { register } = useAuth();
|
|
8
|
+
const { theme, toggleTheme } = useTheme();
|
|
9
|
+
const [fullName, setFullName] = useState('');
|
|
10
|
+
const [email, setEmail] = useState('');
|
|
11
|
+
const [password, setPassword] = useState('');
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
const [message, setMessage] = useState('');
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
async function submit(event: FormEvent) {
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
setError('');
|
|
19
|
+
setMessage('');
|
|
20
|
+
setLoading(true);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await register(fullName, email, password);
|
|
24
|
+
setMessage(result);
|
|
25
|
+
setFullName('');
|
|
26
|
+
setEmail('');
|
|
27
|
+
setPassword('');
|
|
28
|
+
} catch (err) {
|
|
29
|
+
setError(err instanceof Error ? err.message : 'Registration failed.');
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<main className="authPage">
|
|
37
|
+
<section className="authVisual">
|
|
38
|
+
<button className="authThemeBtn" type="button" onClick={toggleTheme}>{theme === 'dark' ? 'Light' : 'Dark'} mode</button>
|
|
39
|
+
<div className="authBadge">Approval workflow</div>
|
|
40
|
+
<h1>Create your workspace account.</h1>
|
|
41
|
+
<p>
|
|
42
|
+
New users are created as inactive. Admins can review and activate the
|
|
43
|
+
account from the Users page before the first login.
|
|
44
|
+
</p>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<form className="authCard" onSubmit={submit}>
|
|
48
|
+
<span className="eyebrow">New account</span>
|
|
49
|
+
<h2>Register</h2>
|
|
50
|
+
<p>After registration, wait for admin activation.</p>
|
|
51
|
+
|
|
52
|
+
{error && <div className="errorBox">{error}</div>}
|
|
53
|
+
{message && <div className="successBox">{message}</div>}
|
|
54
|
+
|
|
55
|
+
<label>
|
|
56
|
+
Full name
|
|
57
|
+
<input value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
|
58
|
+
</label>
|
|
59
|
+
|
|
60
|
+
<label>
|
|
61
|
+
Email
|
|
62
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
|
|
63
|
+
</label>
|
|
64
|
+
|
|
65
|
+
<label>
|
|
66
|
+
Password
|
|
67
|
+
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
<button className="primaryBtn full" disabled={loading} type="submit">
|
|
71
|
+
{loading ? 'Creating...' : 'Create account'}
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
<small>
|
|
75
|
+
Already active? <Link to="/login">Login</Link>
|
|
76
|
+
</small>
|
|
77
|
+
</form>
|
|
78
|
+
</main>
|
|
79
|
+
);
|
|
80
|
+
}
|