新規タスクモーダル
: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">×</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: 注意事項
- 依存パッケージを忘れず追加