SCALE — Build Lab
UI部品 · REACT COMPONENT

新規タスクモーダル

CATEGORYUI部品 TYPEReact Component EFFORT60〜180分 DIFFICULTY
PRIMARY CODE
tsx · components/tasks/NewTaskModal.tsx
"use client";

import { useState } from "react";
import type { Task } from "@/lib/tasks-api";
import { uid, today, useTasks } from "@/lib/tasks-api";
import DatePicker from "./DatePicker";
import EstimateMinutesPicker from "./EstimateMinutesPicker";
import AssigneePicker from "./AssigneePicker";

interface NewTaskModalProps {
  open: boolean;
  onClose: () => void;
  onSave: (task: Task) => void;
}

export default function NewTaskModal({ open, onClose, onSave }: NewTaskModalProps) {
  const { members, currentUser } = useTasks();

  const [name, setName] = useState("");
  const [assignees, setAssignees] = useState<string[]>([]);
  const [due, setDue] = useState("");
  const [memo, setMemo] = useState("");
  const [description, setDescription] = useState(""); // 詳細
  const [link, setLink] = useState("");                 // リンク
  const [estimate, setEstimate] = useState<number | "">(""); // 目安作業時間(分)

  if (!open) return null;

  const handleSave = () => {
    if (!name.trim() || !due) return;
    // 目安作業時間も必須
    if (typeof estimate !== 'number' || estimate <= 0) return;

    const task: Task = {
      id: uid(),
      name: name.trim(),
      assignee: assignees[0] || currentUser, // 後方互換: assignees の先頭 or current
      assignees: assignees.length > 0 ? assignees : [currentUser], // 複数担当
      requester: currentUser, // UIから廃止。後方互換のためログイン中ユーザーを保存
      due,
      originalDue: due,
      priority: 3,        // デフォルト中(UIから入力廃止)
      status: "未着手",
      type: "",          // 種別UIは廃止
      project: "",       // PJTはタスクシート側で後付け運用
      memo,
      link: link.trim(),
      description: description.trim() || undefined,
      estimateMinutes: typeof estimate === 'number' && estimate > 0 ? estimate : undefined,
      todayFlag: false,
      dateChanges: [],
      statusChanges: [],
      editHistory: [],
      comments: [],
      createdBy: currentUser,
      createdAt: today(),
      completedDate: null,
      confirmedBy: null,
    };

    onSave(task);
    handleClose();
  };

  const handleClose = () => {
    setName("");
    setAssignees([]);
    setDue("");
    setMemo("");
    setDescription("");
    setLink("");
    setEstimate("");
    onClose();
  };

  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 =
    "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 appearance-none cursor-pointer";

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={handleClose}>
      <div className="bg-[#111114] border border-[#2a2a36] rounded-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl" onClick={(e) => e.stopPropagation()}>
        {/* Header */}
        <div className="flex items-center justify-between px-6 py-4 border-b border-[#2a2a36]">
          <h2 className="text-white font-semibold text-base">新規タスク</h2>
          <button onClick={handleClose} className="text-[#888] hover:text-white transition-colors text-lg leading-none">&times;</button>
        </div>

        {/* Body */}
        <div className="px-6 py-4 space-y-4">
          {/* Title */}
          <div className="space-y-1">
            <label className={labelClass}>タスク名 <span className="text-red-400">*</span></label>
            <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="タスク名を入力..." className={inputClass} autoFocus />
          </div>

          {/* Assignees(複数選択) */}
          <div className="space-y-1">
            <label className={labelClass}>担当者(複数選択可)</label>
            <AssigneePicker value={assignees} onChange={setAssignees} members={members} placeholder="未設定(自分担当として登録)" />
          </div>

          {/* 期日 / 目安作業時間 */}
          <div className="grid grid-cols-2 gap-3">
            <DatePicker value={due} onChange={setDue} label="期日 *" />
            <div className="space-y-1">
              <label className={labelClass}>目安作業時間 <span className="text-red-400">*</span></label>
              <EstimateMinutesPicker
                value={typeof estimate === 'number' ? estimate : undefined}
                onChange={(v) => setEstimate(typeof v === 'number' ? v : "")}
                selectClassName={selectClass}
                inputClassName={inputClass + ' w-28'}
              />
              {(typeof estimate !== 'number' || estimate <= 0) && (
                <p className="text-[10px] text-red-400/80 mt-1">作業ペース可視化のため必須</p>
              )}
            </div>
          </div>

          {/* 詳細 */}
          <div className="space-y-1">
            <label className={labelClass}>詳細(何をする?完了条件、注意点など)</label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              placeholder="例: 商談議事録から提案ポイントを3点抜き出して資料化、初稿を共有まで"
              rows={4}
              className={`${inputClass} resize-none`}
            />
          </div>

          {/* リンク */}
          <div className="space-y-1">
            <label className={labelClass}>リンク(任意)</label>
            <input
              type="url"
              value={link}
              onChange={(e) => setLink(e.target.value)}
              placeholder="https://… 関連資料・カレンダー予定・ツールなど"
              className={inputClass}
            />
          </div>

          {/* メモ(短い補足) */}
          <div className="space-y-1">
            <label className={labelClass}>メモ(短い補足)</label>
            <textarea value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="ひと言メモ..." rows={2} className={`${inputClass} resize-none`} />
          </div>
        </div>

        {/* Footer */}
        <div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-[#2a2a36]">
          <button onClick={handleClose} 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}
            disabled={!name.trim() || !due || typeof estimate !== 'number' || estimate <= 0}
            title={typeof estimate !== 'number' || estimate <= 0 ? 'タスク名・期日・目安作業時間 は必須です' : ''}
            className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
          >作成</button>
        </div>
      </div>
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理
  • プロジェクト管理

新規タスクモーダル

:LiTarget: 用途

タスク新規作成のモーダル。担当者・期日・推定時間を1画面で入力。

:LiSparkle: 特徴

  • 全フィールド入力
  • バリデーション
  • 繰り返しタスク対応

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: components/tasks/NewTaskModal.tsx の中身そのもの。コピペ即可。

"use client";

import { useState } from "react";
import type { Task } from "@/lib/tasks-api";
import { uid, today, useTasks } from "@/lib/tasks-api";
import DatePicker from "./DatePicker";
import EstimateMinutesPicker from "./EstimateMinutesPicker";
import AssigneePicker from "./AssigneePicker";

interface NewTaskModalProps {
  open: boolean;
  onClose: () => void;
  onSave: (task: Task) => void;
}

export default function NewTaskModal({ open, onClose, onSave }: NewTaskModalProps) {
  const { members, currentUser } = useTasks();

  const [name, setName] = useState("");
  const [assignees, setAssignees] = useState<string[]>([]);
  const [due, setDue] = useState("");
  const [memo, setMemo] = useState("");
  const [description, setDescription] = useState(""); // 詳細
  const [link, setLink] = useState("");                 // リンク
  const [estimate, setEstimate] = useState<number | "">(""); // 目安作業時間(分)

  if (!open) return null;

  const handleSave = () => {
    if (!name.trim() || !due) return;
    // 目安作業時間も必須
    if (typeof estimate !== 'number' || estimate <= 0) return;

    const task: Task = {
      id: uid(),
      name: name.trim(),
      assignee: assignees[0] || currentUser, // 後方互換: assignees の先頭 or current
      assignees: assignees.length > 0 ? assignees : [currentUser], // 複数担当
      requester: currentUser, // UIから廃止。後方互換のためログイン中ユーザーを保存
      due,
      originalDue: due,
      priority: 3,        // デフォルト中(UIから入力廃止)
      status: "未着手",
      type: "",          // 種別UIは廃止
      project: "",       // PJTはタスクシート側で後付け運用
      memo,
      link: link.trim(),
      description: description.trim() || undefined,
      estimateMinutes: typeof estimate === 'number' && estimate > 0 ? estimate : undefined,
      todayFlag: false,
      dateChanges: [],
      statusChanges: [],
      editHistory: [],
      comments: [],
      createdBy: currentUser,
      createdAt: today(),
      completedDate: null,
      confirmedBy: null,
    };

    onSave(task);
    handleClose();
  };

  const handleClose = () => {
    setName("");
    setAssignees([]);
    setDue("");
    setMemo("");
    setDescription("");
    setLink("");
    setEstimate("");
    onClose();
  };

  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 =
    "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 appearance-none cursor-pointer";

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={handleClose}>
      <div className="bg-[#111114] border border-[#2a2a36] rounded-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl" onClick={(e) => e.stopPropagation()}>
        {/* Header */}
        <div className="flex items-center justify-between px-6 py-4 border-b border-[#2a2a36]">
          <h2 className="text-white font-semibold text-base">新規タスク</h2>
          <button onClick={handleClose} className="text-[#888] hover:text-white transition-colors text-lg leading-none">&times;</button>
        </div>

        {/* Body */}
        <div className="px-6 py-4 space-y-4">
          {/* Title */}
          <div className="space-y-1">
            <label className={labelClass}>タスク名 <span className="text-red-400">*</span></label>
            <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="タスク名を入力..." className={inputClass} autoFocus />
          </div>

          {/* Assignees(複数選択) */}
          <div className="space-y-1">
            <label className={labelClass}>担当者(複数選択可)</label>
            <AssigneePicker value={assignees} onChange={setAssignees} members={members} placeholder="未設定(自分担当として登録)" />
          </div>

          {/* 期日 / 目安作業時間 */}
          <div className="grid grid-cols-2 gap-3">
            <DatePicker value={due} onChange={setDue} label="期日 *" />
            <div className="space-y-1">
              <label className={labelClass}>目安作業時間 <span className="text-red-400">*</span></label>
              <EstimateMinutesPicker
                value={typeof estimate === 'number' ? estimate : undefined}
                onChange={(v) => setEstimate(typeof v === 'number' ? v : "")}
                selectClassName={selectClass}
                inputClassName={inputClass + ' w-28'}
              />
              {(typeof estimate !== 'number' || estimate <= 0) && (
                <p className="text-[10px] text-red-400/80 mt-1">作業ペース可視化のため必須</p>
              )}
            </div>
          </div>

          {/* 詳細 */}
          <div className="space-y-1">
            <label className={labelClass}>詳細(何をする?完了条件、注意点など)</label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              placeholder="例: 商談議事録から提案ポイントを3点抜き出して資料化、初稿を共有まで"
              rows={4}
              className={`${inputClass} resize-none`}
            />
          </div>

          {/* リンク */}
          <div className="space-y-1">
            <label className={labelClass}>リンク(任意)</label>
            <input
              type="url"
              value={link}
              onChange={(e) => setLink(e.target.value)}
              placeholder="https://… 関連資料・カレンダー予定・ツールなど"
              className={inputClass}
            />
          </div>

          {/* メモ(短い補足) */}
          <div className="space-y-1">
            <label className={labelClass}>メモ(短い補足)</label>
            <textarea value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="ひと言メモ..." rows={2} className={`${inputClass} resize-none`} />
          </div>
        </div>

        {/* Footer */}
        <div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-[#2a2a36]">
          <button onClick={handleClose} 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}
            disabled={!name.trim() || !due || typeof estimate !== 'number' || estimate <= 0}
            title={typeof estimate !== 'number' || estimate <= 0 ? 'タスク名・期日・目安作業時間 は必須です' : ''}
            className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
          >作成</button>
        </div>
      </div>
    </div>
  );
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/tasks/NewTaskModal.tsx

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

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