Files
hemhub/frontend/src/TaskBoard.tsx
2026-07-25 10:31:37 +02:00

250 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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