461 lines
13 KiB
TypeScript
461 lines
13 KiB
TypeScript
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">
|
||
1–99 poäng beroende på 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
|