Files
coms3011a-lab1/components/Board.tsx
2026-08-11 20:56:13 +02:00

431 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import type { Task, TaskInput, TaskSort, TaskStatus, TaskUpdate } from "@/types/task";
import AddTaskForm from "./AddTaskForm";
import Column from "./Column";
import EditTaskForm from "./EditTaskForm";
import TaskCard from "./TaskCard";
type ViewMode = "board" | "list";
type TasksPayload = {
tasks: Task[];
};
type TaskPayload = {
task: Task;
};
async function readJson<T>(response: Response): Promise<T> {
const payload = (await response.json()) as T & { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? "Request failed.");
}
return payload;
}
export default function Board() {
const [tasks, setTasks] = useState<Task[]>([]);
const [sortMode, setSortMode] = useState<TaskSort>("createdAt");
const [viewMode, setViewMode] = useState<ViewMode>("board");
const [includeArchived, setIncludeArchived] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [popupTask, setPopupTask] = useState<Task | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTasks = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const searchParams = new URLSearchParams({
sort: sortMode,
includeArchived: String(includeArchived),
});
const payload = await readJson<TasksPayload>(
await fetch(`/api/tasks?${searchParams.toString()}`)
);
setTasks(payload.tasks);
} catch (loadError) {
setError(
loadError instanceof Error ? loadError.message : "Tasks could not be loaded."
);
} finally {
setIsLoading(false);
}
}, [includeArchived, sortMode]);
useEffect(() => {
// Tasks are external server state, so the initial sync starts here.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadTasks();
}, [loadTasks]);
const createNewTask = async (task: TaskInput) => {
await readJson<TaskPayload>(
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(task),
})
);
await loadTasks();
};
const saveTaskChanges = async (id: number, updates: TaskUpdate) => {
await readJson<TaskPayload>(
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...updates }),
})
);
await loadTasks();
};
const handleStatusChange = async (taskId: number, newStatus: TaskStatus) => {
try {
await saveTaskChanges(taskId, { status: newStatus });
} catch (statusError) {
setError(
statusError instanceof Error
? statusError.message
: "Task status could not be changed."
);
}
};
const handleArchive = async (task: Task) => {
try {
await readJson<TaskPayload>(
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: task.id, action: "archive" }),
})
);
await loadTasks();
} catch (archiveError) {
setError(
archiveError instanceof Error
? archiveError.message
: "Task could not be archived."
);
}
};
const handleStatusClick = (task: Task) => {
if (task.status !== "Complete" && !task.archivedAt) {
setPopupTask(task);
}
};
const handleConfirmStatus = async (yes: boolean) => {
const task = popupTask;
setPopupTask(null);
if (!task || !yes) {
return;
}
if (task.status === "Todo") {
await handleStatusChange(task.id, "In-Progress");
} else if (task.status === "In-Progress") {
await handleStatusChange(task.id, "Complete");
}
};
const renderBoard = () => (
<div style={styles.board}>
<Column
title="Todo"
tasks={tasks.filter((task) => task.status === "Todo")}
onAdd={() => setShowForm(true)}
onArchive={handleArchive}
onEdit={setEditingTask}
onStatusClick={handleStatusClick}
/>
<Column
title="In-Progress"
tasks={tasks.filter((task) => task.status === "In-Progress")}
onArchive={handleArchive}
onEdit={setEditingTask}
onStatusClick={handleStatusClick}
/>
<Column
title="Complete"
tasks={tasks.filter((task) => task.status === "Complete")}
onArchive={handleArchive}
onEdit={setEditingTask}
onStatusClick={handleStatusClick}
/>
</div>
);
const renderList = () => (
<div style={styles.list}>
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onArchive={handleArchive}
onEdit={setEditingTask}
onStatusClick={handleStatusClick}
/>
))}
</div>
);
return (
<>
<div style={styles.toolbar}>
<div style={styles.segmented}>
<button
type="button"
style={viewMode === "board" ? styles.activeSegment : styles.segment}
onClick={() => setViewMode("board")}
>
Board
</button>
<button
type="button"
style={viewMode === "list" ? styles.activeSegment : styles.segment}
onClick={() => setViewMode("list")}
>
List
</button>
</div>
<label style={styles.controlLabel}>
Sort
<select
value={sortMode}
onChange={(event) => setSortMode(event.target.value as TaskSort)}
style={styles.select}
>
<option value="createdAt">Newest</option>
<option value="topic">Topic</option>
<option value="status">Status</option>
<option value="dueDate">Due Date</option>
</select>
</label>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={includeArchived}
onChange={(event) => setIncludeArchived(event.target.checked)}
/>
Show archived
</label>
<button
type="button"
style={styles.addButton}
onClick={() => setShowForm(true)}
>
Add Task
</button>
</div>
{error && <div style={styles.error}>{error}</div>}
{isLoading && <div style={styles.emptyState}>Loading tasks...</div>}
{!isLoading && tasks.length === 0 && (
<div style={styles.emptyState}>No tasks yet.</div>
)}
{!isLoading && (
<>{viewMode === "board" ? renderBoard() : renderList()}</>
)}
{showForm && (
<AddTaskForm onSubmit={createNewTask} onClose={() => setShowForm(false)} />
)}
{editingTask && (
<EditTaskForm
task={editingTask}
onClose={() => setEditingTask(null)}
onSubmit={(updates) => saveTaskChanges(editingTask.id, updates)}
/>
)}
{popupTask && (
<div style={styles.overlay}>
<div style={styles.modal}>
<h3 style={styles.modalTitle}>
{popupTask.status === "Todo"
? "Are you ready to start the task?"
: "Have you finished this task?"}
</h3>
<div style={styles.actions}>
<button
type="button"
onClick={() => void handleConfirmStatus(true)}
style={styles.saveButton}
>
Yes
</button>
<button
type="button"
onClick={() => void handleConfirmStatus(false)}
style={styles.cancelButton}
>
No
</button>
</div>
</div>
</div>
)}
</>
);
}
const styles = {
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "16px",
flexWrap: "wrap" as const,
marginBottom: "24px",
},
segmented: {
display: "flex",
border: "1px solid #bae6fd",
borderRadius: "8px",
overflow: "hidden",
background: "#f8fafc",
},
segment: {
background: "#f8fafc",
color: "#0f172a",
border: "none",
padding: "8px 14px",
cursor: "pointer",
fontWeight: "700",
},
activeSegment: {
background: "#0f172a",
color: "white",
border: "none",
padding: "8px 14px",
cursor: "pointer",
fontWeight: "700",
},
controlLabel: {
display: "flex",
alignItems: "center",
gap: "8px",
color: "white",
fontWeight: "700",
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "8px",
color: "white",
fontWeight: "700",
},
select: {
padding: "8px",
border: "1px solid #bae6fd",
borderRadius: "8px",
color: "#0f172a",
fontWeight: "700",
},
addButton: {
background: "#0f172a",
color: "white",
border: "1px solid #0f172a",
borderRadius: "8px",
padding: "8px 14px",
cursor: "pointer",
fontWeight: "700",
},
board: {
display: "flex",
gap: "20px",
justifyContent: "center",
alignItems: "stretch",
},
list: {
maxWidth: "760px",
margin: "0 auto",
},
error: {
maxWidth: "760px",
margin: "0 auto 16px",
background: "#fee2e2",
color: "#991b1b",
border: "1px solid #fecaca",
borderRadius: "8px",
padding: "10px 12px",
fontWeight: "700",
},
emptyState: {
background: "#f8fafc",
color: "#0f172a",
borderRadius: "8px",
padding: "24px",
maxWidth: "420px",
margin: "0 auto",
textAlign: "center" as const,
fontWeight: "700",
},
overlay: {
position: "fixed" as const,
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.4)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
},
modal: {
background: "#f8fafc",
padding: "25px",
borderRadius: "12px",
boxShadow: "0 10px 25px rgba(0,0,0,0.2)",
width: "300px",
textAlign: "center" as const,
},
modalTitle: {
marginBottom: "15px",
fontSize: "18px",
fontWeight: "600",
color: "#0f172a",
},
actions: {
display: "flex",
justifyContent: "space-between",
marginTop: "15px",
},
saveButton: {
background: "#0ea5e9",
color: "white",
border: "none",
padding: "8px 16px",
borderRadius: "8px",
cursor: "pointer",
fontWeight: "600",
},
cancelButton: {
background: "#e2e8f0",
color: "#0f172a",
border: "none",
padding: "8px 16px",
borderRadius: "8px",
cursor: "pointer",
fontWeight: "600",
},
};