Files
hemhub/frontend/src/TaskBoard.tsx
2026-07-26 22:49:06 +02:00

461 lines
13 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 UserSummary = {
id: string
name: string
}
type Assignee = UserSummary
type Task = {
id: string
title: string
description: string | null
status: TaskStatus
points: number
assignee: Assignee | null
createdAt: string
}
type ApiError = {
message?: string
}
type TaskBoardProps = {
activeUserName: string
users: UserSummary[]
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, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false)
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState<Set<string>>(new Set())
const [assignmentErrors, setAssignmentErrors] = useState<Record<string, string>>({})
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()
}, [])
const updateAssignee = async (task: Task, assigneeId: string) => {
if (savingAssigneeTaskIds.has(task.id)) {
return
}
setSavingAssigneeTaskIds((current) => new Set(current).add(task.id))
setAssignmentErrors((current) => ({ ...current, [task.id]: '' }))
try {
const response = await fetch(`/api/tasks/${task.id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: assigneeId || null }),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setAssignmentErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.',
}))
return
}
const updatedTask = (await response.json()) as Task
setTasks((current) =>
current.map((currentTask) => (currentTask.id === updatedTask.id ? updatedTask : currentTask)),
)
setEditingAssigneeTaskId(null)
} catch {
setAssignmentErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra ansvarig. Försök igen.',
}))
} finally {
setSavingAssigneeTaskIds((current) => {
const next = new Set(current)
next.delete(task.id)
return next
})
}
}
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}>
<div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssigneeTaskId === task.id}
saving={savingAssigneeTaskIds.has(task.id)}
error={assignmentErrors[task.id]}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
</article>
))}
</div>
</section>
))}
</section>
{showCreateTask && (
<CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
setShowCreateTask(false)
}}
/>
)}
</main>
)
}
type AssigneeControlProps = {
task: Task
users: UserSummary[]
editing: boolean
saving: boolean
error?: string
onEdit: () => void
onChange: (assigneeId: string) => void
}
function UserIcon() {
return (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d="M5 20c.5-4 3-6 7-6s6.5 2 7 6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
)
}
function AssigneeControl({
task,
users,
editing,
saving,
error,
onEdit,
onChange,
}: AssigneeControlProps) {
const displayName = task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad')
if (task.status !== 'WAITING') {
return (
<div className="task-assignee task-assignee-static">
<UserIcon />
<span>{displayName}</span>
</div>
)
}
return (
<div className="task-assignment">
{editing ? (
<label className="assignee-select-label">
<span className="visually-hidden">Ansvarig för {task.title}</span>
<UserIcon />
<select
aria-label={`Ansvarig för ${task.title}`}
value={task.assignee?.id ?? ''}
disabled={saving}
autoFocus
onChange={(event) => onChange(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
) : (
<button
type="button"
className="task-assignee task-assignee-button"
disabled={saving}
onClick={onEdit}
aria-label={`Ändra ansvarig för ${task.title}`}
>
<UserIcon />
<span>{displayName}</span>
</button>
)}
{error && (
<p className="assignment-error error" role="alert">
{error}
</p>
)}
</div>
)
}
type CreateTaskModalProps = {
users: UserSummary[]
onClose: () => void
onCreated: (task: Task) => void
}
function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
const [assigneeId, setAssigneeId] = 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()
const numericPoints = Number(points)
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
}
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setError('Poäng måste vara ett heltal mellan 1 och 99.')
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,
points: numericPoints,
assigneeId: assigneeId || 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 noValidate 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)}
/>
<label htmlFor="task-points">Poäng</label>
<input
id="task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={isSubmitting}
aria-describedby="task-points-help"
onChange={(event) => setPoints(event.target.value)}
/>
<p id="task-points-help" className="field-help">
199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är.
</p>
<label className="field-label-uppercase" htmlFor="task-assignee">
Tilldela
</label>
<select
id="task-assignee"
value={assigneeId}
disabled={isSubmitting}
onChange={(event) => setAssigneeId(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar…' : 'Skapa uppgift'}
</button>
</form>
</section>
</div>
)
}
export default TaskBoard