@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,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
+ }