@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,45 @@
|
|
|
1
|
+
import type { TaskItem } from '../types';
|
|
2
|
+
import { formatDate, formatDateTime } from '../utils/date';
|
|
3
|
+
import { label } from '../utils/labels';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
task: TaskItem;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TaskDetailsModal({ task, onClose }: Props) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="modalLayer" role="presentation" onMouseDown={onClose}>
|
|
13
|
+
<section className="modalCard wideModal detailsModal" onMouseDown={(e) => e.stopPropagation()}>
|
|
14
|
+
<div className="modalHeader">
|
|
15
|
+
<div>
|
|
16
|
+
<span className="eyebrow">Task #{task.id}</span>
|
|
17
|
+
<h2>{task.title}</h2>
|
|
18
|
+
</div>
|
|
19
|
+
<button type="button" className="iconBtn" onClick={onClose}>×</button>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div className="detailPills">
|
|
23
|
+
<span className={`statusPill ${task.status}`}>{label(task.status)}</span>
|
|
24
|
+
<span className={`priorityBadge ${task.priority}`}>{task.priority}</span>
|
|
25
|
+
<span className="softBadge">{task.visibility}</span>
|
|
26
|
+
{task.isOverdue && <span className="dangerBadge">Overdue</span>}
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div className="descriptionBox">
|
|
30
|
+
{task.description || 'No description was added.'}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div className="detailsGrid">
|
|
34
|
+
<span>ID <strong>#{task.id}</strong></span>
|
|
35
|
+
<span>Due <strong>{formatDate(task.dueDate)}</strong></span>
|
|
36
|
+
<span>Owner <strong>{task.createdByFullName}</strong></span>
|
|
37
|
+
<span>Assigned <strong>{task.assignedToFullName ?? 'Unassigned'}</strong></span>
|
|
38
|
+
<span>Created <strong>{formatDateTime(task.createdAt)}</strong></span>
|
|
39
|
+
<span>Updated <strong>{formatDateTime(task.updatedAt)}</strong></span>
|
|
40
|
+
<span>Pending requests <strong>{task.pendingEditRequestCount}</strong></span>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { TaskPriority, TaskStatus, TaskVisibility } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface TaskFiltersValue {
|
|
4
|
+
q: string;
|
|
5
|
+
status: '' | TaskStatus;
|
|
6
|
+
priority: '' | TaskPriority;
|
|
7
|
+
visibility: '' | TaskVisibility;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
value: TaskFiltersValue;
|
|
12
|
+
onChange: (value: TaskFiltersValue) => void;
|
|
13
|
+
showVisibility?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TaskFilters({ value, onChange, showVisibility = true }: Props) {
|
|
17
|
+
return (
|
|
18
|
+
<section className="filtersBar">
|
|
19
|
+
<input
|
|
20
|
+
value={value.q}
|
|
21
|
+
onChange={(event) => onChange({ ...value, q: event.target.value })}
|
|
22
|
+
placeholder="Search tasks..."
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<select
|
|
26
|
+
value={value.status}
|
|
27
|
+
onChange={(event) => onChange({
|
|
28
|
+
...value,
|
|
29
|
+
status: event.target.value as TaskFiltersValue['status']
|
|
30
|
+
})}
|
|
31
|
+
>
|
|
32
|
+
<option value="">All statuses</option>
|
|
33
|
+
<option value="Todo">Todo</option>
|
|
34
|
+
<option value="InProgress">In Progress</option>
|
|
35
|
+
<option value="Done">Done</option>
|
|
36
|
+
<option value="Cancelled">Cancelled</option>
|
|
37
|
+
</select>
|
|
38
|
+
|
|
39
|
+
<select
|
|
40
|
+
value={value.priority}
|
|
41
|
+
onChange={(event) => onChange({
|
|
42
|
+
...value,
|
|
43
|
+
priority: event.target.value as TaskFiltersValue['priority']
|
|
44
|
+
})}
|
|
45
|
+
>
|
|
46
|
+
<option value="">All priorities</option>
|
|
47
|
+
<option value="Low">Low</option>
|
|
48
|
+
<option value="Medium">Medium</option>
|
|
49
|
+
<option value="High">High</option>
|
|
50
|
+
</select>
|
|
51
|
+
|
|
52
|
+
{showVisibility && (
|
|
53
|
+
<select
|
|
54
|
+
value={value.visibility}
|
|
55
|
+
onChange={(event) => onChange({
|
|
56
|
+
...value,
|
|
57
|
+
visibility: event.target.value as TaskFiltersValue['visibility']
|
|
58
|
+
})}
|
|
59
|
+
>
|
|
60
|
+
<option value="">All visibility</option>
|
|
61
|
+
<option value="Private">Private</option>
|
|
62
|
+
<option value="Public">Public</option>
|
|
63
|
+
</select>
|
|
64
|
+
)}
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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 | null;
|
|
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 TaskModal({ task, users, onClose, onSaved }: Props) {
|
|
18
|
+
const [title, setTitle] = useState('');
|
|
19
|
+
const [description, setDescription] = useState('');
|
|
20
|
+
const [status, setStatus] = useState<TaskStatus>('Todo');
|
|
21
|
+
const [priority, setPriority] = useState<TaskPriority>('Medium');
|
|
22
|
+
const [visibility, setVisibility] = useState<TaskVisibility>('Private');
|
|
23
|
+
const [dueDate, setDueDate] = useState('');
|
|
24
|
+
const [assignedToUserId, setAssignedToUserId] = useState('');
|
|
25
|
+
const [error, setError] = useState('');
|
|
26
|
+
const [saving, setSaving] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!task) {
|
|
30
|
+
setTitle('');
|
|
31
|
+
setDescription('');
|
|
32
|
+
setStatus('Todo');
|
|
33
|
+
setPriority('Medium');
|
|
34
|
+
setVisibility('Private');
|
|
35
|
+
setDueDate('');
|
|
36
|
+
setAssignedToUserId('');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setTitle(task.title);
|
|
41
|
+
setDescription(task.description);
|
|
42
|
+
setStatus(task.status);
|
|
43
|
+
setPriority(task.priority);
|
|
44
|
+
setVisibility(task.visibility);
|
|
45
|
+
setDueDate(toDateInputValue(task.dueDate));
|
|
46
|
+
setAssignedToUserId(task.assignedToUserId ?? '');
|
|
47
|
+
}, [task]);
|
|
48
|
+
|
|
49
|
+
async function submit(event: FormEvent) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
setSaving(true);
|
|
52
|
+
setError('');
|
|
53
|
+
|
|
54
|
+
const body = {
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
status,
|
|
58
|
+
priority,
|
|
59
|
+
visibility,
|
|
60
|
+
dueDate: fromDateInputValue(dueDate),
|
|
61
|
+
clearDueDate: dueDate === '',
|
|
62
|
+
assignedToUserId: assignedToUserId || null,
|
|
63
|
+
clearAssignedUser: assignedToUserId === ''
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (task) {
|
|
68
|
+
await api<TaskItem>(`/api/tasks/${task.id}`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
body: JSON.stringify(body)
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
await api<TaskItem>('/api/tasks', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
body: JSON.stringify(body)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onSaved();
|
|
80
|
+
onClose();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError(err instanceof Error ? err.message : 'Could not save task.');
|
|
83
|
+
} finally {
|
|
84
|
+
setSaving(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="modalLayer" role="presentation" onMouseDown={onClose}>
|
|
90
|
+
<form className="modalCard wideModal taskEditModal" onSubmit={submit} onMouseDown={(e) => e.stopPropagation()}>
|
|
91
|
+
<div className="modalHeader">
|
|
92
|
+
<div>
|
|
93
|
+
<span className="eyebrow">{task ? `Task #${task.id}` : 'New task'}</span>
|
|
94
|
+
<h2>{task ? 'Edit task' : 'Add task'}</h2>
|
|
95
|
+
<p>Keep the modal centered. The description field scrolls independently.</p>
|
|
96
|
+
</div>
|
|
97
|
+
<button type="button" className="iconBtn" onClick={onClose}>×</button>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{error && <div className="errorBox">{error}</div>}
|
|
101
|
+
|
|
102
|
+
<div className="modalBodyGrid">
|
|
103
|
+
<label className="spanTwo">
|
|
104
|
+
Title
|
|
105
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
|
106
|
+
</label>
|
|
107
|
+
|
|
108
|
+
<label className="spanTwo descriptionField">
|
|
109
|
+
Description
|
|
110
|
+
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
111
|
+
</label>
|
|
112
|
+
|
|
113
|
+
<label>
|
|
114
|
+
Status
|
|
115
|
+
<select value={status} onChange={(e) => setStatus(e.target.value as TaskStatus)}>
|
|
116
|
+
{statusOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
117
|
+
</select>
|
|
118
|
+
</label>
|
|
119
|
+
|
|
120
|
+
<label>
|
|
121
|
+
Priority
|
|
122
|
+
<select value={priority} onChange={(e) => setPriority(e.target.value as TaskPriority)}>
|
|
123
|
+
{priorityOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
124
|
+
</select>
|
|
125
|
+
</label>
|
|
126
|
+
|
|
127
|
+
<label>
|
|
128
|
+
Visibility
|
|
129
|
+
<select value={visibility} onChange={(e) => setVisibility(e.target.value as TaskVisibility)}>
|
|
130
|
+
{visibilityOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
131
|
+
</select>
|
|
132
|
+
</label>
|
|
133
|
+
|
|
134
|
+
<label>
|
|
135
|
+
Due date
|
|
136
|
+
<input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
|
137
|
+
</label>
|
|
138
|
+
|
|
139
|
+
<label className="spanTwo">
|
|
140
|
+
Assign to
|
|
141
|
+
<select value={assignedToUserId} onChange={(e) => setAssignedToUserId(e.target.value)}>
|
|
142
|
+
<option value="">Unassigned</option>
|
|
143
|
+
{users.filter((user) => user.isActive).map((user) => (
|
|
144
|
+
<option key={user.id} value={user.id}>{user.fullName}</option>
|
|
145
|
+
))}
|
|
146
|
+
</select>
|
|
147
|
+
</label>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div className="modalActions">
|
|
151
|
+
<button type="button" className="ghostBtn" onClick={onClose}>Cancel</button>
|
|
152
|
+
<button type="submit" className="primaryBtn" disabled={saving}>
|
|
153
|
+
{saving ? 'Saving...' : 'Save task'}
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</form>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
tasks: TaskItem[];
|
|
8
|
+
onView: (task: TaskItem) => void;
|
|
9
|
+
onEdit?: (task: TaskItem) => void;
|
|
10
|
+
onDelete?: (task: TaskItem) => void;
|
|
11
|
+
onRequestEdit?: (task: TaskItem) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TaskTable({ tasks, onView, onEdit, onDelete, onRequestEdit }: Props) {
|
|
15
|
+
const { user, isAdmin } = useAuth();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<section className="tableCard taskTableCard">
|
|
19
|
+
<table>
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<th>ID</th>
|
|
23
|
+
<th>Task</th>
|
|
24
|
+
<th>Status</th>
|
|
25
|
+
<th>Priority</th>
|
|
26
|
+
<th>Visibility</th>
|
|
27
|
+
<th>Due</th>
|
|
28
|
+
<th>Owner</th>
|
|
29
|
+
<th>Assigned</th>
|
|
30
|
+
<th>Requests</th>
|
|
31
|
+
<th></th>
|
|
32
|
+
</tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
{tasks.map((task) => {
|
|
36
|
+
const canWrite = isAdmin || task.createdByUserId === user?.id;
|
|
37
|
+
const canRequestEdit = !canWrite && task.visibility === 'Public';
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<tr key={task.id}>
|
|
41
|
+
<td className="monoCell">#{task.id}</td>
|
|
42
|
+
<td>
|
|
43
|
+
<strong>{task.title}</strong>
|
|
44
|
+
{task.description && <small>{task.description}</small>}
|
|
45
|
+
</td>
|
|
46
|
+
<td><span className={`statusPill ${task.status}`}>{label(task.status)}</span></td>
|
|
47
|
+
<td><span className={`priorityBadge ${task.priority}`}>{task.priority}</span></td>
|
|
48
|
+
<td>{task.visibility}</td>
|
|
49
|
+
<td>{formatDate(task.dueDate)}</td>
|
|
50
|
+
<td>{task.createdByFullName}</td>
|
|
51
|
+
<td>{task.assignedToFullName ?? 'Unassigned'}</td>
|
|
52
|
+
<td>{task.pendingEditRequestCount}</td>
|
|
53
|
+
<td className="rowActions">
|
|
54
|
+
<button className="ghostBtn" onClick={() => onView(task)}>View</button>
|
|
55
|
+
{canWrite && onEdit && <button className="ghostBtn" onClick={() => onEdit(task)}>Edit</button>}
|
|
56
|
+
{canRequestEdit && onRequestEdit && (
|
|
57
|
+
<button className="ghostBtn" onClick={() => onRequestEdit(task)}>Request</button>
|
|
58
|
+
)}
|
|
59
|
+
{canWrite && onDelete && <button className="dangerBtn" onClick={() => onDelete(task)}>Delete</button>}
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import { api } from '../api/http';
|
|
3
|
+
import type { User, UserRole } from '../types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
user?: User | null;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onSaved: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function UserModal({ user, onClose, onSaved }: Props) {
|
|
12
|
+
const [fullName, setFullName] = useState('');
|
|
13
|
+
const [email, setEmail] = useState('');
|
|
14
|
+
const [password, setPassword] = useState('');
|
|
15
|
+
const [role, setRole] = useState<UserRole>('User');
|
|
16
|
+
const [isActive, setIsActive] = useState(true);
|
|
17
|
+
const [saving, setSaving] = useState(false);
|
|
18
|
+
const [error, setError] = useState('');
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!user) return;
|
|
22
|
+
|
|
23
|
+
setFullName(user.fullName);
|
|
24
|
+
setEmail(user.email);
|
|
25
|
+
setRole(user.role);
|
|
26
|
+
setIsActive(user.isActive);
|
|
27
|
+
}, [user]);
|
|
28
|
+
|
|
29
|
+
async function submit(event: FormEvent) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
setSaving(true);
|
|
32
|
+
setError('');
|
|
33
|
+
|
|
34
|
+
const body = {
|
|
35
|
+
fullName,
|
|
36
|
+
email,
|
|
37
|
+
password: password || undefined,
|
|
38
|
+
role,
|
|
39
|
+
isActive
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (user) {
|
|
44
|
+
await api<User>(`/api/users/${user.id}`, {
|
|
45
|
+
method: 'PUT',
|
|
46
|
+
body: JSON.stringify(body)
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
await api<User>('/api/users', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: JSON.stringify({ ...body, password })
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onSaved();
|
|
56
|
+
onClose();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(err instanceof Error ? err.message : 'Could not save user.');
|
|
59
|
+
} finally {
|
|
60
|
+
setSaving(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="modalLayer" role="presentation" onMouseDown={onClose}>
|
|
66
|
+
<form className="modalCard" onSubmit={submit} onMouseDown={(e) => e.stopPropagation()}>
|
|
67
|
+
<div className="modalHeader">
|
|
68
|
+
<div>
|
|
69
|
+
<span className="eyebrow">User</span>
|
|
70
|
+
<h2>{user ? 'Edit user' : 'Add user'}</h2>
|
|
71
|
+
</div>
|
|
72
|
+
<button type="button" className="iconBtn" onClick={onClose}>×</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{error && <div className="errorBox">{error}</div>}
|
|
76
|
+
|
|
77
|
+
<label>
|
|
78
|
+
Full name
|
|
79
|
+
<input value={fullName} onChange={(e) => setFullName(e.target.value)} required />
|
|
80
|
+
</label>
|
|
81
|
+
|
|
82
|
+
<label>
|
|
83
|
+
Email
|
|
84
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
|
85
|
+
</label>
|
|
86
|
+
|
|
87
|
+
<label>
|
|
88
|
+
Password {user && <small>leave empty to keep current password</small>}
|
|
89
|
+
<input
|
|
90
|
+
type="password"
|
|
91
|
+
value={password}
|
|
92
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
93
|
+
required={!user}
|
|
94
|
+
/>
|
|
95
|
+
</label>
|
|
96
|
+
|
|
97
|
+
<div className="formGrid">
|
|
98
|
+
<label>
|
|
99
|
+
Role
|
|
100
|
+
<select value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
|
101
|
+
<option value="User">User</option>
|
|
102
|
+
<option value="Admin">Admin</option>
|
|
103
|
+
</select>
|
|
104
|
+
</label>
|
|
105
|
+
|
|
106
|
+
<label>
|
|
107
|
+
Status
|
|
108
|
+
<select value={isActive ? 'true' : 'false'} onChange={(e) => setIsActive(e.target.value === 'true')}>
|
|
109
|
+
<option value="true">Active</option>
|
|
110
|
+
<option value="false">Inactive</option>
|
|
111
|
+
</select>
|
|
112
|
+
</label>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="modalActions">
|
|
116
|
+
<button type="button" className="ghostBtn" onClick={onClose}>Cancel</button>
|
|
117
|
+
<button type="submit" className="primaryBtn" disabled={saving}>
|
|
118
|
+
{saving ? 'Saving...' : 'Save user'}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</form>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import App from './App';
|
|
5
|
+
import { AuthProvider } from './auth/AuthContext';
|
|
6
|
+
import { ThemeProvider } from './theme';
|
|
7
|
+
import './styles.css';
|
|
8
|
+
|
|
9
|
+
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
|
10
|
+
<React.StrictMode>
|
|
11
|
+
<BrowserRouter>
|
|
12
|
+
<ThemeProvider>
|
|
13
|
+
<AuthProvider>
|
|
14
|
+
<App />
|
|
15
|
+
</AuthProvider>
|
|
16
|
+
</ThemeProvider>
|
|
17
|
+
</BrowserRouter>
|
|
18
|
+
</React.StrictMode>
|
|
19
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api/http';
|
|
3
|
+
import { PageHeader } from '../components/PageHeader';
|
|
4
|
+
import type { ActivityLog } from '../types';
|
|
5
|
+
import { formatDateTime } from '../utils/date';
|
|
6
|
+
|
|
7
|
+
export function ActivityPage() {
|
|
8
|
+
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
void api<ActivityLog[]>('/api/activity').then(setLogs);
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<section>
|
|
16
|
+
<PageHeader
|
|
17
|
+
eyebrow="Admin"
|
|
18
|
+
title="Activity"
|
|
19
|
+
description="Track the main actions happening in the system."
|
|
20
|
+
/>
|
|
21
|
+
|
|
22
|
+
<section className="timelineCard">
|
|
23
|
+
{logs.map((log) => (
|
|
24
|
+
<article key={log.id} className="timelineItem">
|
|
25
|
+
<span className="timelineDot" />
|
|
26
|
+
<div>
|
|
27
|
+
<strong>{log.action}</strong>
|
|
28
|
+
<p>{log.entityName} · {log.userFullName}</p>
|
|
29
|
+
<small>{formatDateTime(log.createdAt)}</small>
|
|
30
|
+
</div>
|
|
31
|
+
</article>
|
|
32
|
+
))}
|
|
33
|
+
{logs.length === 0 && <div className="emptyState">No activity yet.</div>}
|
|
34
|
+
</section>
|
|
35
|
+
</section>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api/http';
|
|
3
|
+
import { PageHeader } from '../components/PageHeader';
|
|
4
|
+
import { TaskCard } from '../components/TaskCard';
|
|
5
|
+
import type { TaskItem, TaskStatus } from '../types';
|
|
6
|
+
import { label } from '../utils/labels';
|
|
7
|
+
|
|
8
|
+
const statuses: TaskStatus[] = ['Todo', 'InProgress', 'Done', 'Cancelled'];
|
|
9
|
+
|
|
10
|
+
export function BoardPage() {
|
|
11
|
+
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
|
12
|
+
const [draggingTask, setDraggingTask] = useState<TaskItem | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
void load();
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
async function load() {
|
|
19
|
+
setTasks(await api<TaskItem[]>('/api/tasks/my'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function moveTask(status: TaskStatus) {
|
|
23
|
+
if (!draggingTask || draggingTask.status === status) return;
|
|
24
|
+
|
|
25
|
+
const updated = await api<TaskItem>(`/api/tasks/${draggingTask.id}/status`, {
|
|
26
|
+
method: 'PATCH',
|
|
27
|
+
body: JSON.stringify({ status })
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
|
|
31
|
+
setDraggingTask(null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<section>
|
|
36
|
+
<PageHeader
|
|
37
|
+
eyebrow="Board"
|
|
38
|
+
title="Task Board"
|
|
39
|
+
description="Drag tasks between columns to change their status."
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<div className="boardGrid">
|
|
43
|
+
{statuses.map((status) => {
|
|
44
|
+
const columnTasks = tasks.filter((task) => task.status === status);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section
|
|
48
|
+
key={status}
|
|
49
|
+
className="boardColumn"
|
|
50
|
+
onDragOver={(event) => event.preventDefault()}
|
|
51
|
+
onDrop={() => void moveTask(status)}
|
|
52
|
+
>
|
|
53
|
+
<div className="boardHeader">
|
|
54
|
+
<strong>{label(status)}</strong>
|
|
55
|
+
<span>{columnTasks.length}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="boardTasks">
|
|
59
|
+
{columnTasks.map((task) => (
|
|
60
|
+
<TaskCard
|
|
61
|
+
key={task.id}
|
|
62
|
+
task={task}
|
|
63
|
+
draggable
|
|
64
|
+
onDragStart={setDraggingTask}
|
|
65
|
+
/>
|
|
66
|
+
))}
|
|
67
|
+
{columnTasks.length === 0 && <p className="emptyColumn">Drop tasks here</p>}
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|