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([]) const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [showCreateTask, setShowCreateTask] = useState(false) const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState(null) const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState>(new Set()) const [assignmentErrors, setAssignmentErrors] = useState>({}) 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 (

HemHub

Uppgifter

{activeUserName}
{loadState === 'loading' && 'Laddar uppgifter…'} {loadState === 'error' && ( <> Kunde inte hämta uppgifter. )}
{columns.map((column) => (

{column.title}

{tasks .filter((task) => task.status === column.status) .map((task) => (

{task.title}

{task.points} p
{task.description &&

{task.description}

} setEditingAssigneeTaskId(task.id)} onChange={(assigneeId) => void updateAssignee(task, assigneeId)} />
))}
))}
{showCreateTask && ( setShowCreateTask(false)} onCreated={(task) => { setTasks((currentTasks) => [...currentTasks, task]) setShowCreateTask(false) }} /> )}
) } type AssigneeControlProps = { task: Task users: UserSummary[] editing: boolean saving: boolean error?: string onEdit: () => void onChange: (assigneeId: string) => void } function UserIcon() { return ( ) } 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 (
{displayName}
) } return (
{editing ? ( ) : ( )} {error && (

{error}

)}
) } 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) => { if (event.target === event.currentTarget && !isSubmittingRef.current) { onClose() } } const submit = async (event: FormEvent) => { 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 (

Skapa ny uppgift

void submit(event)}> setTitle(event.target.value)} />