feat: add task assignment

This commit is contained in:
Urban Modig
2026-07-26 22:49:06 +02:00
parent 059d4da921
commit aaebe888f3
26 changed files with 964 additions and 39 deletions

View File

@ -2,12 +2,20 @@ 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
}
@ -17,6 +25,7 @@ type ApiError = {
type TaskBoardProps = {
activeUserName: string
users: UserSummary[]
onLogOut: () => void
}
@ -26,10 +35,13 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' },
]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
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')
@ -52,6 +64,49 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
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">
@ -98,6 +153,15 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
<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>
@ -107,6 +171,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{showCreateTask && (
<CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
@ -118,15 +183,110 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
)
}
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({ onClose, onCreated }: CreateTaskModalProps) {
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)
@ -191,6 +351,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
assigneeId: assigneeId || null,
}),
})
@ -264,6 +425,23 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
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}