fix(chat,todo): show sent chat messages immediately + kanban drag-and-drop reorder
チャット送信の即時表示とKanbanのDnD並べ替えを修正。 - chat: 送信成功時に返却メッセージを楽観的にローカルへ先頭追加(SSE到着前でも即表示、idで重複排除)。setSending(false) をレスポンス処理後に移動し二重送信窓を縮小。 - todo: TodoService.reorderItems(projectId, columnId, itemIds[]) を追加(1トランザクションでカラム内を再採番、メンバーシップ+同プロジェクト検証、活動ログ/SSEは1件)。POST /todos/items/reorder を追加。 - KanbanBoard: カード単位のドロップターゲット(ポインタYで前/後挿入)+カラム空き領域は末尾追加。楽観的にorderIndex/columnIdを更新しrenderをorderIndexでソート(即時反映)、失敗時はエラー表示+再取得で巻き戻し。ドラッグ直後の誤クリック(ダイアログ誘起)をdragEndタイムスタンプで抑制。空の並べ替えはSSE/ログを出さない。
This commit is contained in:
@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: input, fileIds }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
// 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。
|
||||
// SSEの chat.message.created は id で重複排除する。
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
message?: ChatMessageWithAttachments;
|
||||
} | null;
|
||||
if (data?.message) {
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id === data.message!.id)
|
||||
? prev
|
||||
: [data.message!, ...prev]
|
||||
);
|
||||
}
|
||||
setInput('');
|
||||
pickerRef.current?.clear();
|
||||
} else {
|
||||
@ -91,6 +102,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '送信に失敗しました');
|
||||
}
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, type DragEvent } from 'react';
|
||||
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
||||
import type { TodoColumn, TodoItem } from '@/lib/types';
|
||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||
import { TodoDialog } from '@/components/todo/TodoDialog';
|
||||
@ -11,6 +11,11 @@ const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
|
||||
/** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */
|
||||
function byOrder(a: TodoItem, b: TodoItem): number {
|
||||
return a.orderIndex - b.orderIndex || a.id - b.id;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
projectId,
|
||||
columns,
|
||||
@ -25,6 +30,9 @@ export function KanbanBoard({
|
||||
const [items, setItems] = useState<TodoItem[]>(initialItems);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ
|
||||
const lastDragEndAtRef = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
||||
@ -40,27 +48,84 @@ export function KanbanBoard({
|
||||
e.dataTransfer.setData('text/plain', String(itemId));
|
||||
}
|
||||
|
||||
function applyReorder(
|
||||
prev: TodoItem[],
|
||||
destColumnId: number,
|
||||
newOrderIds: number[]
|
||||
): TodoItem[] {
|
||||
const order = new Map(newOrderIds.map((id, idx) => [id, idx]));
|
||||
return prev.map((i) =>
|
||||
order.has(i.id)
|
||||
? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! }
|
||||
: i
|
||||
);
|
||||
}
|
||||
|
||||
function columnItemIds(columnId: number, excludeId?: number): number[] {
|
||||
return items
|
||||
.filter((i) => i.columnId === columnId && i.id !== excludeId)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id)
|
||||
.map((i) => i.id);
|
||||
}
|
||||
|
||||
async function persistReorder(
|
||||
destColumnId: number,
|
||||
newOrderIds: number[]
|
||||
): Promise<void> {
|
||||
setItems((prev) => applyReorder(prev, destColumnId, newOrderIds));
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/todos/items/reorder`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
columnId: destColumnId,
|
||||
itemIds: newOrderIds,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('並べ替えに失敗しました');
|
||||
}
|
||||
} catch {
|
||||
setError('並べ替えに失敗しました');
|
||||
} finally {
|
||||
await reload();
|
||||
}
|
||||
setDraggingId(null);
|
||||
}
|
||||
|
||||
// カラムの空き領域へのドロップ = 末尾に追加
|
||||
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
|
||||
e.preventDefault();
|
||||
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (!itemId) return;
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item || item.columnId === column.id) {
|
||||
if (!itemId) {
|
||||
setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
// 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
|
||||
);
|
||||
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => reload());
|
||||
setDraggingId(null);
|
||||
const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
|
||||
void persistReorder(column.id, newOrderIds);
|
||||
}
|
||||
|
||||
// カード上へのドロップ = ポインタ位置で前後に挿入
|
||||
function dropOnItem(e: DragEvent<HTMLElement>, target: TodoItem) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (!itemId || itemId === target.id) {
|
||||
setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientY - rect.top < rect.height / 2;
|
||||
const ordered = columnItemIds(target.columnId, itemId);
|
||||
const insertAt = before
|
||||
? ordered.indexOf(target.id)
|
||||
: ordered.indexOf(target.id) + 1;
|
||||
ordered.splice(insertAt, 0, itemId);
|
||||
void persistReorder(target.columnId, ordered);
|
||||
}
|
||||
|
||||
async function addTask(columnId: number, title: string) {
|
||||
@ -82,116 +147,142 @@ export function KanbanBoard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
|
||||
{columns.map((column) => {
|
||||
const colItems = items.filter((i) => i.columnId === column.id);
|
||||
return (
|
||||
<section
|
||||
key={column.id}
|
||||
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, column)}
|
||||
data-testid={`kanban-column-${column.id}`}
|
||||
data-column-id={column.id}
|
||||
>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{column.name} ({colItems.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{colItems.map((item) => {
|
||||
const tags = item.tags
|
||||
?.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const assignee = members.find(
|
||||
(m) => m.user.id === item.assigneeId
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className="text-sm hover:text-blue-600 hover:underline"
|
||||
data-testid={`todo-open-${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleComplete(item.id);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ??
|
||||
PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.startDate && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
開始: {item.startDate}
|
||||
</span>
|
||||
)}
|
||||
{assignee && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
@{assignee.user.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem && (
|
||||
<TodoDialog
|
||||
projectId={projectId}
|
||||
item={selectedItem}
|
||||
members={members}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
className="mb-2 text-sm text-red-600"
|
||||
role="alert"
|
||||
data-testid="kanban-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-4"
|
||||
data-testid="kanban-board"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const colItems = items
|
||||
.filter((i) => i.columnId === column.id)
|
||||
.sort(byOrder);
|
||||
return (
|
||||
<section
|
||||
key={column.id}
|
||||
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, column)}
|
||||
data-testid={`kanban-column-${column.id}`}
|
||||
data-column-id={column.id}
|
||||
>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{column.name} ({colItems.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{colItems.map((item) => {
|
||||
const tags = item.tags
|
||||
?.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const assignee = members.find(
|
||||
(m) => m.user.id === item.assigneeId
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
onDragEnd={() => {
|
||||
lastDragEndAtRef.current = Date.now();
|
||||
setDraggingId(null);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => dropOnItem(e, item)}
|
||||
onClick={() => {
|
||||
// ドラッグ直後の誤クリックでダイアログが開かないようにする
|
||||
if (Date.now() - lastDragEndAtRef.current < 300) return;
|
||||
setSelectedItem(item);
|
||||
}}
|
||||
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className="text-sm hover:text-blue-600 hover:underline"
|
||||
data-testid={`todo-open-${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleComplete(item.id);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ??
|
||||
PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.startDate && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
開始: {item.startDate}
|
||||
</span>
|
||||
)}
|
||||
{assignee && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
@{assignee.user.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem && (
|
||||
<TodoDialog
|
||||
projectId={projectId}
|
||||
item={selectedItem}
|
||||
members={members}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user