SCALE — Build Lab
UI部品 · REACT COMPONENT

タスク詳細モーダル

CATEGORYUI部品 TYPEReact Component EFFORT120〜300分 DIFFICULTY
PRIMARY CODE
tsx · 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">&times;</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>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理
  • 案件管理

タスク詳細モーダル

: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">&times;</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: 注意事項

  • 依存パッケージを忘れず追加