feat(kanban): DnD + PATCH – flytta tasks mellan kolumner med optimistisk uppdatering
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@ -13,6 +13,9 @@
|
|||||||
"test:run": "vitest --run"
|
"test:run": "vitest --run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -24,6 +27,7 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
75
pnpm-lock.yaml
generated
75
pnpm-lock.yaml
generated
@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility':
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1(react@19.2.0)
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.0))
|
version: 5.2.2(react-hook-form@7.64.0(react@19.2.0))
|
||||||
@ -41,6 +50,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.9.4
|
specifier: ^7.9.4
|
||||||
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
@ -260,6 +272,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.10':
|
'@esbuild/aix-ppc64@0.25.10':
|
||||||
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -1655,6 +1689,12 @@ packages:
|
|||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
|
sonner@2.0.7:
|
||||||
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -1759,6 +1799,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -2115,6 +2158,31 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.4': {}
|
'@csstools/css-tokenizer@3.0.4': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.10':
|
'@esbuild/aix-ppc64@0.25.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -3395,6 +3463,11 @@ snapshots:
|
|||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
@ -3476,6 +3549,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { Outlet, NavLink } from 'react-router-dom'
|
import { Outlet, NavLink } from 'react-router-dom'
|
||||||
import { MeBadge } from '../../features/me/MeBadge'
|
import { MeBadge } from '../../features/me/MeBadge'
|
||||||
import { useAuth } from '@/auth/useAuth'
|
import { useAuth } from '@/auth/useAuth'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
|
||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
const { isAuthenticated, signIn } = useAuth()
|
const { isAuthenticated, signIn } = useAuth()
|
||||||
@ -24,6 +25,7 @@ export function RootLayout() {
|
|||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster richColors closeButton />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,27 +2,29 @@ import { api } from '@/lib/http'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { Task, TaskStatus } from '@/types/task'
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
|
||||||
export function projectTasksKey(projectId: string, params?: Record<string, string | number | undefined>) {
|
export function projectTasksKey(
|
||||||
|
projectId: string,
|
||||||
|
params?: Record<string, string | number | undefined>
|
||||||
|
) {
|
||||||
return ['projectTasks', projectId, params] as const
|
return ['projectTasks', projectId, params] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProjectTasks(projectId: string, params?: { status?: TaskStatus }) {
|
type Page<T> = { content: T[] }
|
||||||
|
|
||||||
|
export async function fetchProjectTasks(
|
||||||
|
projectId: string,
|
||||||
|
params?: { status?: TaskStatus }
|
||||||
|
): Promise<Task[]> {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
if (params?.status) search.set('status', params.status)
|
if (params?.status) search.set('status', params.status)
|
||||||
|
|
||||||
const url = `api/v1/projects/${projectId}/tasks${search.size ? `?${search.toString()}` : ''}`
|
const url = `api/v1/projects/${projectId}/tasks${search.size ? `?${search.toString()}` : ''}`
|
||||||
|
|
||||||
// Läs råsvaret och normalisera till Task[]
|
const raw = await api.get(url).json<unknown>()
|
||||||
const raw = await api.get(url).json<any>()
|
if (Array.isArray(raw)) return raw as Task[]
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as Page<Task>).content)) {
|
||||||
// Stöd både ren lista och Page<T>
|
return (raw as Page<Task>).content
|
||||||
const list: unknown =
|
}
|
||||||
Array.isArray(raw) ? raw
|
return []
|
||||||
: Array.isArray(raw?.content) ? raw.content
|
|
||||||
: Array.isArray(raw?.items) ? raw.items
|
|
||||||
: []
|
|
||||||
|
|
||||||
return list as Task[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectTasksQuery(projectId: string) {
|
export function useProjectTasksQuery(projectId: string) {
|
||||||
|
|||||||
12
src/features/tasks/components/DraggableTaskCard.tsx
Normal file
12
src/features/tasks/components/DraggableTaskCard.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
import { TaskCard } from './TaskCard'
|
||||||
|
|
||||||
|
export function DraggableTaskCard({ task }: { task: Task }) {
|
||||||
|
const { attributes, listeners, setNodeRef } = useDraggable({ id: task.id })
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||||
|
<TaskCard task={task} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/features/tasks/components/DroppableColumn.tsx
Normal file
50
src/features/tasks/components/DroppableColumn.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useDroppable } from '@dnd-kit/core'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import { DraggableTaskCard } from './DraggableTaskCard'
|
||||||
|
|
||||||
|
export function DroppableColumn({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
status: TaskStatus
|
||||||
|
items: Task[]
|
||||||
|
}) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({ id: status })
|
||||||
|
const empty = items.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`col-${status}`} className="flex-1 min-w-[260px]">
|
||||||
|
<header className="mb-2 flex items-center justify-between">
|
||||||
|
<h3
|
||||||
|
id={`col-${status}`}
|
||||||
|
className="text-sm font-semibold uppercase tracking-wide opacity-70"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs opacity-60">{items.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
role="list"
|
||||||
|
aria-describedby={`col-${status}`}
|
||||||
|
className={[
|
||||||
|
'space-y-2 rounded-lg p-2 transition',
|
||||||
|
'min-h-[240px] border-2 border-dashed',
|
||||||
|
empty ? 'bg-gray-50 dark:bg-zinc-900/40' : 'bg-white/50 dark:bg-zinc-900/50',
|
||||||
|
isOver ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950/30' : 'border-gray-200',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{empty ? (
|
||||||
|
<div className="text-xs text-gray-500 italic">
|
||||||
|
Släpp här för att flytta till {title}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((t) => <DraggableTaskCard key={t.id} task={t} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,10 +2,11 @@ import type { Task } from '@/types/task'
|
|||||||
|
|
||||||
export function TaskCard({ task }: { task: Task }) {
|
export function TaskCard({ task }: { task: Task }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
role="listitem"
|
role="listitem"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="rounded-xl border bg-white px-3 py-2 shadow-sm outline-offset-2 focus:outline focus:outline-2 focus:outline-indigo-500"
|
className="rounded-xl border bg-white dark:bg-zinc-800 px-3 py-2 shadow-sm
|
||||||
|
hover:shadow-md transition outline-offset-2 focus:outline focus:outline-2 focus:outline-indigo-500"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h4 className="text-sm font-medium leading-5">{task.title}</h4>
|
<h4 className="text-sm font-medium leading-5">{task.title}</h4>
|
||||||
@ -14,11 +15,13 @@ export function TaskCard({ task }: { task: Task }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<div className="mt-1 text-xs opacity-70">Due: {new Date(task.dueDate).toLocaleDateString()}</div>
|
<div className="mt-1 text-xs opacity-70">
|
||||||
|
Due: {new Date(task.dueDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.assigneeName && (
|
{task.assigneeName && (
|
||||||
<div className="mt-1 text-xs opacity-70">Assignee: {task.assigneeName}</div>
|
<div className="mt-1 text-xs opacity-70">Assignee: {task.assigneeName}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
47
src/features/tasks/mutations.ts
Normal file
47
src/features/tasks/mutations.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '@/lib/http'
|
||||||
|
import { projectTasksKey } from './api'
|
||||||
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
async function patchTaskStatus(taskId: string, status: TaskStatus) {
|
||||||
|
await api.patch(`api/v1/tasks/${taskId}`, { json: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTaskStatusMutation(projectId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ taskId, status }: { taskId: string; status: TaskStatus }) =>
|
||||||
|
patchTaskStatus(taskId, status),
|
||||||
|
|
||||||
|
onMutate: async ({ taskId, status }) => {
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
await qc.cancelQueries({ queryKey: key })
|
||||||
|
|
||||||
|
const prev = qc.getQueryData<Task[]>(key)
|
||||||
|
if (Array.isArray(prev)) {
|
||||||
|
const next: Task[] = prev.map((t) => (t.id === taskId ? { ...t, status } : t))
|
||||||
|
qc.setQueryData<Task[]>(key, next) // ← optimistisk flytt i UI
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading('Uppdaterar task…', { id: `task-${taskId}` })
|
||||||
|
return { prev, taskId }
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
if (ctx?.prev) qc.setQueryData<Task[]>(key, ctx.prev) // rollback
|
||||||
|
if (ctx?.taskId)
|
||||||
|
toast.error('Kunde inte uppdatera – ändring ångrad', { id: `task-${ctx.taskId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success('Task uppdaterad', { id: `task-${vars.taskId}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
const key = projectTasksKey(projectId)
|
||||||
|
qc.invalidateQueries({ queryKey: key }) // sync med server
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,20 +1,36 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useProjectTasksQuery } from '@/features/tasks/api'
|
import { useProjectTasksQuery } from '@/features/tasks/api'
|
||||||
import type { Task } from '@/types/task'
|
import type { Task, TaskStatus } from '@/types/task'
|
||||||
import { KanbanColumn } from '@/features/tasks/components/KanbanColumn'
|
import {
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCorners,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { DroppableColumn } from '@/features/tasks/components/DroppableColumn'
|
||||||
|
import { useUpdateTaskStatusMutation } from '@/features/tasks/mutations'
|
||||||
|
|
||||||
function splitByStatus(items: Task[] | unknown) {
|
function splitByStatus(items: Task[]) {
|
||||||
const arr = Array.isArray(items) ? items as Task[] : []
|
|
||||||
return {
|
return {
|
||||||
OPEN: arr.filter(t => t.status === 'OPEN'),
|
OPEN: items.filter((t) => t.status === 'OPEN'),
|
||||||
IN_PROGRESS: arr.filter(t => t.status === 'IN_PROGRESS'),
|
IN_PROGRESS: items.filter((t) => t.status === 'IN_PROGRESS'),
|
||||||
DONE: arr.filter(t => t.status === 'DONE'),
|
DONE: items.filter((t) => t.status === 'DONE'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskStatus(x: unknown): x is TaskStatus {
|
||||||
|
return x === 'OPEN' || x === 'IN_PROGRESS' || x === 'DONE'
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectBoardPage() {
|
export function ProjectBoardPage() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
const { data, isLoading, isError } = useProjectTasksQuery(projectId)
|
const { data, isLoading, isError } = useProjectTasksQuery(projectId)
|
||||||
|
const { mutate } = useUpdateTaskStatusMutation(projectId)
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor))
|
||||||
|
|
||||||
if (isLoading) return <p>Laddar tasks…</p>
|
if (isLoading) return <p>Laddar tasks…</p>
|
||||||
if (isError) return <p>Något gick fel när tasks skulle hämtas.</p>
|
if (isError) return <p>Något gick fel när tasks skulle hämtas.</p>
|
||||||
@ -22,11 +38,23 @@ export function ProjectBoardPage() {
|
|||||||
const tasks = data ?? []
|
const tasks = data ?? []
|
||||||
const cols = splitByStatus(tasks)
|
const cols = splitByStatus(tasks)
|
||||||
|
|
||||||
|
const onDragEnd = (e: DragEndEvent) => {
|
||||||
|
console.log('onDragEnd', { active: e.active.id, over: e.over?.id })
|
||||||
|
const taskId = String(e.active.id)
|
||||||
|
const dest = e.over?.id
|
||||||
|
if (!dest || typeof dest !== 'string') return
|
||||||
|
const toStatus = dest.toUpperCase()
|
||||||
|
if (!isTaskStatus(toStatus)) return
|
||||||
|
mutate({ taskId, status: toStatus })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
|
||||||
<KanbanColumn title="Open" status="OPEN" items={cols.OPEN ?? []} />
|
<div className="grid grid-cols-3 gap-4"> {/* håll 3 kolumner medan du testar */}
|
||||||
<KanbanColumn title="In progress" status="IN_PROGRESS" items={cols.IN_PROGRESS} />
|
<DroppableColumn title="Open" status="OPEN" items={cols.OPEN} />
|
||||||
<KanbanColumn title="Done" status="DONE" items={cols.DONE} />
|
<DroppableColumn title="In progress" status="IN_PROGRESS" items={cols.IN_PROGRESS} />
|
||||||
</div>
|
<DroppableColumn title="Done" status="DONE" items={cols.DONE} />
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user