feat: add task creation board

This commit is contained in:
Urban Modig
2026-07-25 10:31:37 +02:00
parent 050f248857
commit 3f152eeccc
16 changed files with 962 additions and 29 deletions

249
frontend/src/TaskBoard.tsx Normal file
View File

@ -0,0 +1,249 @@
import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type Task = {
id: string
title: string
description: string | null
status: TaskStatus
createdAt: string
}
type ApiError = {
message?: string
}
type TaskBoardProps = {
activeUserName: string
onLogOut: () => void
}
const columns: { status: TaskStatus; title: string }[] = [
{ status: 'WAITING', title: 'Väntande' },
{ status: 'IN_PROGRESS', title: 'Pågående' },
{ status: 'COMPLETED', title: 'Klart' },
]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false)
const loadTasks = async () => {
setLoadState('loading')
try {
const response = await fetch('/api/tasks')
if (!response.ok) {
throw new Error('Kunde inte hämta uppgifter')
}
setTasks((await response.json()) as Task[])
setLoadState('ready')
} catch {
setLoadState('error')
}
}
useEffect(() => {
void loadTasks()
}, [])
return (
<main className="task-app">
<header className="app-header">
<div>
<p className="eyebrow">HemHub</p>
<h1>Uppgifter</h1>
</div>
<div className="user-controls">
<span>{activeUserName}</span>
<button type="button" className="secondary compact" onClick={onLogOut}>
Logga ut
</button>
</div>
</header>
<div className="board-toolbar">
<div aria-live="polite">
{loadState === 'loading' && 'Laddar uppgifter…'}
{loadState === 'error' && (
<>
Kunde inte hämta uppgifter.
<button type="button" className="link-button" onClick={() => void loadTasks()}>
Försök igen
</button>
</>
)}
</div>
<button type="button" onClick={() => setShowCreateTask(true)}>
Ny uppgift
</button>
</div>
<section className="board" aria-label="Uppgiftsbräda">
{columns.map((column) => (
<section className="board-column" key={column.status} aria-labelledby={column.status}>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks
.filter((task) => task.status === column.status)
.map((task) => (
<article className="task-card" key={task.id}>
<h3>{task.title}</h3>
{task.description && <p>{task.description}</p>}
</article>
))}
</div>
</section>
))}
</section>
{showCreateTask && (
<CreateTaskModal
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
setShowCreateTask(false)
}}
/>
)}
</main>
)
}
type CreateTaskModalProps = {
onClose: () => void
onCreated: (task: Task) => void
}
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSubmittingRef.current) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !isSubmittingRef.current) {
onClose()
}
}
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmittingRef.current) {
return
}
const trimmedTitle = title.trim()
const trimmedDescription = description.trim()
if (!trimmedTitle || [...trimmedTitle].length > 100) {
setError('Titeln måste innehålla mellan 1 och 100 tecken.')
return
}
if ([...trimmedDescription].length > 500) {
setError('Beskrivningen får innehålla högst 500 tecken.')
return
}
setError('')
isSubmittingRef.current = true
setIsSubmitting(true)
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: trimmedTitle,
description: trimmedDescription || null,
}),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setError(apiError.message ?? 'Det gick inte att skapa uppgiften. Försök igen.')
return
}
onCreated((await response.json()) as Task)
} catch {
setError('Det gick inte att skapa uppgiften. Försök igen.')
} finally {
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-task-title"
>
<div className="modal-header">
<h2 id="create-task-title">Skapa ny uppgift</h2>
<button
type="button"
className="close-button"
aria-label="Stäng"
disabled={isSubmitting}
onClick={onClose}
>
×
</button>
</div>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label>
<input
id="task-title"
autoFocus
value={title}
disabled={isSubmitting}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="task-description">Beskrivning (valfri)</label>
<textarea
id="task-description"
rows={5}
value={description}
disabled={isSubmitting}
onChange={(event) => setDescription(event.target.value)}
/>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar…' : 'Skapa uppgift'}
</button>
</form>
</section>
</div>
)
}
export default TaskBoard