Files
opengroupware/components/todo/TodoDialog.tsx
Ken Yasue 91dd05ba7b feat(todo): richer metadata (tags, startDate, files) + edit/detail dialog
ToDoアイテムのメタデータを拡充し、編集/詳細ダイアログを追加。

- lib/db/migrations/003_todo_tags.sql: todo_items に tags 列を追加
- TodoRepository/TodoService: startDate(従来ハードコードNULL)とtagsをcreate/updateで永続化、fileIdsで添付紐付け(トランザクション)、deleteItemで添付クリーンアップ、getItem/getItemAttachments を追加
- API: GET /todos/items/[itemId] ({item, attachments}) を追加、POST/PATCH で startDate/tags/fileIds を受理、PATCH は null(クリア)と undefined(更新しない)を区別
- UI: TodoDialog(タイトル/説明/担当/優先度/開始日/期限/タグ/添付を編集、完了日時・タイムスタンプは読み取り専用)、KanbanBoard のカードクリックでダイアログを開きメタデータを表示
- MilestoneRepository の todo マッピングに tags を追加し as never キャストを修正
- docs/functional-design.md に tags / file_assets.source / attachments テーブルを追記
2026-06-25 10:51:50 +02:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useRef, useState } from 'react';
import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
import { AttachmentList } from '@/components/files/AttachmentList';
import {
AttachmentPicker,
type AttachmentPickerHandle,
} from '@/components/files/AttachmentPicker';
const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
function parseTags(tags: string | null): string[] {
if (!tags) return [];
return tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
/**
* ToDo編集/詳細ダイアログ。
* カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を
* 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。
*/
export function TodoDialog({
projectId,
item,
members,
onClose,
onSaved,
}: {
projectId: number;
item: TodoItem;
members: ProjectMemberWithUser[];
onClose: () => void;
onSaved: () => void | Promise<void>;
}) {
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description ?? '');
const [assigneeId, setAssigneeId] = useState<string>(
item.assigneeId ? String(item.assigneeId) : ''
);
const [priority, setPriority] = useState<TodoPriority>(item.priority);
const [startDate, setStartDate] = useState(item.startDate ?? '');
const [dueDate, setDueDate] = useState(item.dueDate ?? '');
const [tags, setTags] = useState(item.tags ?? '');
const [attachments, setAttachments] = useState<AttachmentView[]>([]);
const [current, setCurrent] = useState<TodoItem>(item);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerRef = useRef<AttachmentPickerHandle>(null);
// 開封時に最新のアイテム+添付を取得
useEffect(() => {
let alive = true;
setLoading(true);
fetch(`/api/projects/${projectId}/todos/items/${item.id}`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!alive || !data) return;
const fetched = data.item as TodoItem;
setTitle(fetched.title);
setDescription(fetched.description ?? '');
setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : '');
setPriority(fetched.priority);
setStartDate(fetched.startDate ?? '');
setDueDate(fetched.dueDate ?? '');
setTags(fetched.tags ?? '');
setAttachments((data.attachments as AttachmentView[]) ?? []);
setCurrent(fetched);
})
.catch(() => undefined)
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [projectId, item.id]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function onSave() {
setSaving(true);
setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(
`/api/projects/${projectId}/todos/items/${item.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
assigneeId: assigneeId ? Number(assigneeId) : null,
priority,
startDate: startDate || null,
dueDate: dueDate || null,
tags: tags || null,
fileIds,
}),
}
);
setSaving(false);
if (res.ok) {
pickerRef.current?.clear();
await onSaved();
onClose();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '保存に失敗しました');
}
}
const tagList = parseTags(tags);
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="todo-dialog-backdrop"
>
<div
className="mt-8 w-full max-w-xl rounded-lg bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`ToDo ${item.title} の編集`}
data-testid="todo-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">ToDoの編集</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="閉じる"
data-testid="todo-dialog-close"
>
×
</button>
</div>
{loading ? (
<p className="p-6 text-sm text-gray-400">...</p>
) : (
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
<div>
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-title"
/>
</div>
<div>
<label className="block text-sm font-medium"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 min-h-[80px] w-full rounded border px-3 py-2"
data-testid="todo-description"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-assignee"
>
<option value=""></option>
{members.map((m) => (
<option key={m.user.id} value={String(m.user.id)}>
{m.user.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium"></label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as TodoPriority)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-priority"
>
{PRIORITIES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-start-date"
/>
</div>
<div>
<label className="block text-sm font-medium">
(deadline)
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-due-date"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium">
()
</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="frontend, urgent"
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-tags"
/>
{tagList.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tagList.map((t) => (
<span
key={t}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
>
{t}
</span>
))}
</div>
)}
</div>
<div>
<p className="mb-1 text-sm font-medium"></p>
{attachments.length > 0 ? (
<AttachmentList attachments={attachments} />
) : (
<p className="text-xs text-gray-400"></p>
)}
<div className="mt-2">
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
</div>
</div>
<div className="space-y-1 border-t pt-3 text-xs text-gray-500">
<p>: {current.completedAt ?? '未完了'}</p>
<p>: {current.createdAt}</p>
<p>: {current.updatedAt}</p>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2 border-t p-4">
<button
type="button"
onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
</button>
<button
type="button"
onClick={onSave}
disabled={saving || pickerLoading || loading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="todo-save"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
}