タスク詳細モーダル
:LiTarget: 用途
タスクの全情報を編集可能なモーダル。サブタスク・コメント・履歴対応。
:LiSparkle: 特徴
- 全フィールド編集
- サブタスク管理
- コメント機能
- 変更履歴
:LiCode: 実コード(SCALE Base より自動抽出)
:LiInfo:
components/tasks/TaskDetailModal.tsxの中身そのもの。コピペ即可。
"use client";
import { useState, useEffect } from "react";
import type { Task } from "@/lib/tasks-api";
import {
STATUSES,
TYPES,
fmtDate,
now,
useTasks,
} from "@/lib/tasks-api";
import DatePicker from "./DatePicker";
import EstimateMinutesPicker from "./EstimateMinutesPicker";
import AssigneePicker from "./AssigneePicker";
import { ExternalLink, Plus, X, ChevronDown, ChevronUp, Clock, Tag, FolderKanban, Sparkles } from "lucide-react";
interface TaskDetailModalProps {
task: Task | null;
onClose: () => void;
onSave: (task: Task) => void;
onDelete?: (id: string) => void;
}
function HistoryItem({ label, detail, time }: { label: string; detail: string; time?: string }) {
return (
<div className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#444] mt-1.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[#ccc]">{label}</span>
<span className="text-[#666] ml-1">{detail}</span>
</div>
{time && <span className="text-[#555] shrink-0">{time}</span>}
</div>
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold text-[#888] uppercase tracking-wider mb-2">
{children}
</h3>
);
}
const ORIGIN_LABELS: Record<string, { label: string; color: string }> = {
manual: { label: '手動追加', color: 'bg-[#0c0c0f] text-[#888] border-[#2a2a36]' },
morning: { label: '朝日報', color: 'bg-amber-500/15 text-amber-300 border-amber-500/30' },
active: { label: '作業中追加', color: 'bg-blue-500/15 text-blue-300 border-blue-500/30' },
end: { label: '作業終了', color: 'bg-violet-500/15 text-violet-300 border-violet-500/30' },
'slack-bot': { label: 'Slack 📝', color: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' },
routine: { label: '定例', color: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30' },
};
export default function TaskDetailModal({
task,
onClose,
onSave,
onDelete,
}: TaskDetailModalProps) {
const { members, projects, currentUser } = useTasks();
const [name, setName] = useState("");
const [status, setStatus] = useState("");
const [assignees, setAssignees] = useState<string[]>([]);
const [due, setDue] = useState("");
const [memo, setMemo] = useState("");
const [todayFlag, setTodayFlag] = useState(false);
const [newComment, setNewComment] = useState("");
// 新規フィールド
const [description, setDescription] = useState("");
const [links, setLinks] = useState<{ label?: string; url: string }[]>([]);
const [estimateMinutes, setEstimateMinutes] = useState<number | undefined>(undefined);
// 折り畳み
const [showHistory, setShowHistory] = useState(false);
// リンク追加用
const [newLinkUrl, setNewLinkUrl] = useState("");
const [newLinkLabel, setNewLinkLabel] = useState("");
useEffect(() => {
if (!task) return;
setName(task.name);
setStatus(task.status);
// assignees 優先、無ければ assignee 単数を1件配列に
const initial = (task.assignees && task.assignees.length > 0)
? task.assignees
: (task.assignee ? [task.assignee] : []);
setAssignees(initial);
setDue(task.due);
setMemo(task.memo ?? "");
setTodayFlag(task.todayFlag ?? false);
setNewComment("");
setDescription(task.description ?? "");
// links: 新フィールド優先、なければ link(旧単数)から1件として復元
const restoredLinks = (task.links && task.links.length > 0)
? task.links
: (task.link ? [{ url: task.link }] : []);
setLinks(restoredLinks);
setEstimateMinutes(task.estimateMinutes);
setShowHistory(false);
setNewLinkUrl("");
setNewLinkLabel("");
}, [task]);
if (!task) return null;
const handleDueDateChange = (newDate: string) => {
if (newDate !== due && due) {
const reason = window.prompt("期日変更の理由を入力してください:");
if (!reason) return;
}
setDue(newDate);
};
// 複数担当に変更(既存と差分があれば理由をプロンプト)
const handleAssigneesChange = (next: string[]) => {
const before = [...assignees].sort().join(',');
const after = [...next].sort().join(',');
if (before && before !== after) {
const reason = window.prompt("担当者変更の理由を入力してください(キャンセルで取り消し):");
if (!reason) return;
}
setAssignees(next);
};
const addLink = () => {
const url = newLinkUrl.trim();
if (!url) return;
setLinks(prev => [...prev, { url, label: newLinkLabel.trim() || undefined }]);
setNewLinkUrl("");
setNewLinkLabel("");
};
const removeLink = (idx: number) => setLinks(prev => prev.filter((_, i) => i !== idx));
const handleSave = () => {
const dateChanged = due !== task.due;
const statusChanged = status !== task.status;
const updatedDateChanges = dateChanged
? [...task.dateChanges, { from: task.due, to: due, user: currentUser, time: now(), reason: "期日変更" }]
: task.dateChanges;
const updatedStatusChanges = statusChanged
? [...task.statusChanges, { from: task.status, to: status, user: currentUser, time: now(), reason: "ステータス変更" }]
: task.statusChanges;
const updated: Task = {
...task,
name,
status,
assignee: assignees[0] || '', // 後方互換
assignees, // 複数担当
due,
originalDue: task.originalDue,
memo,
todayFlag,
description,
links,
// 後方互換: link は links[0] のURL を反映
link: links[0]?.url ?? "",
estimateMinutes,
dateChanges: updatedDateChanges,
statusChanges: updatedStatusChanges,
completedDate: status === "完了" && task.status !== "完了" ? now().split(" ")[0] : task.completedDate,
};
onSave(updated);
onClose();
};
const handleDelete = () => {
if (!onDelete) return;
if (window.confirm("このタスクを削除しますか?")) {
onDelete(task.id);
onClose();
}
};
const handleAddComment = () => {
if (!newComment.trim()) return;
const commentLine = `\n[${fmtDate(now().split(" ")[0])} ${currentUser}] ${newComment.trim()}`;
setMemo((prev) => prev + commentLine);
setNewComment("");
};
const labelClass = "text-xs text-[#888] font-medium";
const inputClass =
"w-full bg-[#0c0c0f] border border-[#2a2a36] rounded-lg text-white text-sm px-3 py-2 outline-none focus:border-blue-500/60 transition-colors placeholder:text-[#555]";
const selectClass = inputClass + " appearance-none cursor-pointer";
const originMeta = task.origin && ORIGIN_LABELS[task.origin];
// ステータスタブの色(active時)
const statusTabColor = (s: string) => {
if (s === '完了') return 'bg-emerald-500 text-white border-emerald-500';
if (s === '進行中') return 'bg-blue-500 text-white border-blue-500';
if (s === '確認待ち') return 'bg-amber-500 text-white border-amber-500';
return 'bg-bg3 text-text border-border'; // 未着手
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-[#111114] border border-[#2a2a36] rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-3 border-b border-[#2a2a36] bg-[#111114]/95 backdrop-blur">
<div className="flex items-center gap-2 min-w-0">
{originMeta && (
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded border ${originMeta.color}`}>
{originMeta.label}
</span>
)}
<span className="text-[10px] text-[#555]">#{task.id.slice(0, 6)}</span>
</div>
<button onClick={onClose} className="text-[#888] hover:text-white text-xl leading-none">×</button>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-5">
{/* タスク名(大きく) */}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="タスク名"
className="w-full bg-transparent border-0 text-xl font-bold text-white outline-none focus:bg-[#0c0c0f] rounded px-2 py-1"
/>
{/* ステータスタブ */}
<div className="flex items-center gap-1.5 flex-wrap">
{STATUSES.map((s) => {
const active = status === s;
return (
<button
key={s}
onClick={() => setStatus(s)}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg border transition-colors ${active ? statusTabColor(s) : 'bg-bg3 text-text2 border-border hover:text-text'}`}
>
{s}
</button>
);
})}
</div>
{/* 詳細説明 */}
<div className="space-y-1">
<label className={labelClass + " flex items-center gap-1"}>
<Sparkles size={11} /> タスクの詳細(何のタスクか・完了条件)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="このタスクが何かパッとわかるような説明(Slack📝経由はAIが自動生成)"
rows={3}
className={`${inputClass} resize-y`}
/>
</div>
{/* 関連リンク */}
<div className="space-y-2">
<label className={labelClass + " flex items-center gap-1"}>
<ExternalLink size={11} /> 関連リンク
</label>
{links.length > 0 && (
<div className="space-y-1.5">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2 bg-[#0c0c0f] border border-[#2a2a36] rounded-lg px-3 py-1.5">
<a href={l.url} target="_blank" rel="noopener noreferrer" className="flex-1 text-sm text-blue-400 hover:underline truncate inline-flex items-center gap-1.5">
<ExternalLink size={11} className="shrink-0" />
{l.label || l.url}
</a>
<button onClick={() => removeLink(i)} className="text-text3 hover:text-red-400"><X size={12} /></button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={newLinkLabel}
onChange={(e) => setNewLinkLabel(e.target.value)}
placeholder="ラベル(任意)"
className={inputClass + " w-32"}
/>
<input
type="url"
value={newLinkUrl}
onChange={(e) => setNewLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addLink(); } }}
placeholder="https://..."
className={inputClass + " flex-1"}
/>
<button
onClick={addLink}
disabled={!newLinkUrl.trim()}
className="px-3 py-2 text-xs font-semibold rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors disabled:opacity-40"
>
<Plus size={12} />
</button>
</div>
</div>
{/* 担当 / 期日 / 想定時間 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label className={labelClass}>担当者(複数選択可)</label>
<AssigneePicker value={assignees} onChange={handleAssigneesChange} members={members} placeholder="未設定" />
</div>
<DatePicker value={due} onChange={handleDueDateChange} label="期日" hideQuickPresets />
<div className="space-y-1">
<label className={labelClass + " flex items-center gap-1"}><Clock size={11}/>目安作業時間</label>
<EstimateMinutesPicker value={estimateMinutes} onChange={setEstimateMinutes} />
</div>
</div>
{/* メモ */}
<div className="space-y-1">
<label className={labelClass}>メモ</label>
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="補足メモ・進捗ログなど..."
rows={3}
className={`${inputClass} resize-y`}
/>
</div>
{/* コメント */}
<div className="space-y-2">
<SectionHeader>コメント</SectionHeader>
{task.comments.length > 0 && (
<div className="bg-[#0c0c0f] border border-[#2a2a36] rounded-lg p-3 space-y-1.5 mb-2">
{task.comments.map((c, i) => (
<HistoryItem key={i} label={c.user} detail={c.text} time={c.time} />
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
placeholder="コメントを入力..."
className={`${inputClass} flex-1`}
/>
<button
onClick={handleAddComment}
disabled={!newComment.trim()}
className="px-3 py-2 text-xs text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors shrink-0 disabled:opacity-40"
>
送信
</button>
</div>
</div>
{/* 履歴(折り畳み) */}
{(task.dateChanges.length > 0 || task.statusChanges.length > 0) && (
<div>
<button
onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-1.5 text-xs text-text3 hover:text-text transition-colors"
>
{showHistory ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
変更履歴を{showHistory ? '隠す' : '表示'}
</button>
{showHistory && (
<div className="mt-2 space-y-3">
{task.dateChanges.length > 0 && (
<div>
<SectionHeader>期日変更</SectionHeader>
<div className="bg-[#0c0c0f] border border-[#2a2a36] rounded-lg p-3 space-y-1.5">
{task.dateChanges.map((dc, i) => (
<HistoryItem key={i} label={`${dc.from} → ${dc.to}`} detail={`${dc.user}: ${dc.reason}`} time={dc.time} />
))}
</div>
</div>
)}
{task.statusChanges.length > 0 && (
<div>
<SectionHeader>ステータス変更</SectionHeader>
<div className="bg-[#0c0c0f] border border-[#2a2a36] rounded-lg p-3 space-y-1.5">
{task.statusChanges.map((sc, i) => (
<HistoryItem key={i} label={`${sc.from} → ${sc.to}`} detail={`${sc.user}${sc.reason ? `: ${sc.reason}` : ''}`} time={sc.time} />
))}
</div>
</div>
)}
</div>
)}
</div>
)}
{/* メタ情報 */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-[#555] pt-2 border-t border-[#1a1a24]">
<span>作成日: {fmtDate(task.createdAt)}</span>
{task.completedDate && <span>完了日: {fmtDate(task.completedDate)}</span>}
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 flex items-center justify-between px-6 py-3 border-t border-[#2a2a36] bg-[#111114]/95 backdrop-blur">
<div>
{onDelete && (
<button
onClick={handleDelete}
className="px-3 py-1.5 text-xs text-red-400 hover:text-red-300 border border-red-500/20 hover:border-red-500/40 rounded-lg transition-colors"
>
削除
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[#888] hover:text-white rounded-lg border border-[#2a2a36] hover:border-[#444] transition-colors"
>
キャンセル
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
>
保存
</button>
</div>
</div>
</div>
</div>
);
}
:LiFolder: ソースファイルのパス
/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/tasks/TaskDetailModal.tsx
:LiHandPointer: 使い方
対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。
:LiAlertCircle: 注意事項
- 依存パッケージを忘れず追加