SCALE — Build Lab
UI部品 · REACT COMPONENT

スケジュールモーダル

CATEGORYUI部品 TYPEReact Component EFFORT90〜240分 DIFFICULTY
PRIMARY CODE
tsx · components/tasks/ScheduleModal.tsx
'use client';

// 朝日報で宣言したタスクを Google カレンダーに自動配置するモーダル。
// 各タスクの想定時間を個別設定できる(キーワード推定ベースで自動プリセット、ユーザー上書き可)。

import { useState, useMemo, useEffect } from 'react';
import { X, Clock, Calendar, Sparkles, Check, AlertTriangle } from 'lucide-react';
import { estimateTaskMinutes, fmtMinutes, TIME_PRESETS } from '@/lib/task-estimate';

export interface ScheduleTask {
  key: string;                      // 一意ID(重複排除用)
  name: string;
  kind: 'base' | 'routine' | 'memo';
  taskId?: string;                  // SCALE Base 側のタスクID(紐付け用)
  priority?: number;
}

export interface ScheduleModalProps {
  open: boolean;
  onClose: () => void;
  tasks: ScheduleTask[];            // 配置したいタスク(朝日報から)
  date?: string;                    // YYYY-MM-DD (省略時は今日)
  onScheduled?: (result: { placed: any[]; unplaced: any[] }) => void;
}

const ACCENT = '#3b82f6';

export default function ScheduleModal({ open, onClose, tasks, date, onScheduled }: ScheduleModalProps) {
  const [workStart, setWorkStart] = useState('09:00');
  const [workEnd, setWorkEnd] = useState('18:00');
  const [bufferMinutes, setBufferMinutes] = useState(5);
  // タスクごとの想定時間(key → minutes)
  const [estimates, setEstimates] = useState<Record<string, number>>({});
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<{ placed: any[]; unplaced: any[] } | null>(null);
  const [error, setError] = useState<string>('');

  // 初期化: タスク名から推定
  useEffect(() => {
    if (!open) return;
    const init: Record<string, number> = {};
    const reasons: Record<string, string> = {};
    tasks.forEach(t => {
      const est = estimateTaskMinutes(t.name);
      init[t.key] = est.minutes;
      reasons[t.key] = est.reason;
    });
    setEstimates(init);
    setResult(null);
    setError('');
  }, [open, tasks]);

  const totalMinutes = useMemo(() => tasks.reduce((s, t) => s + (estimates[t.key] || 0), 0), [tasks, estimates]);
  const bufferTotal = tasks.length > 1 ? (tasks.length - 1) * bufferMinutes : 0;
  const grandTotal = totalMinutes + bufferTotal;

  // 業務時間の長さ
  const workTotalMin = useMemo(() => {
    const [sh, sm] = workStart.split(':').map(Number);
    const [eh, em] = workEnd.split(':').map(Number);
    return (eh * 60 + em) - (sh * 60 + sm);
  }, [workStart, workEnd]);

  const overBudget = grandTotal > workTotalMin;

  const setMin = (key: string, m: number) => {
    setEstimates(prev => ({ ...prev, [key]: Math.max(5, m) }));
  };

  const applyAll = (m: number) => {
    const next: Record<string, number> = {};
    tasks.forEach(t => { next[t.key] = m; });
    setEstimates(next);
  };

  const aiReEstimate = () => {
    const next: Record<string, number> = {};
    tasks.forEach(t => { next[t.key] = estimateTaskMinutes(t.name).minutes; });
    setEstimates(next);
  };

  const handleSchedule = async () => {
    setLoading(true);
    setError('');
    setResult(null);
    try {
      const payload = {
        date: date || new Date().toISOString().slice(0, 10),
        workStart, workEnd, bufferMinutes,
        tasks: tasks.map(t => ({
          name: t.name,
          estimatedMinutes: estimates[t.key] || 30,
          priority: t.priority || 3,
          taskId: t.taskId,
        })),
      };
      const res = await fetch('/api/google-calendar/auto-schedule', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error === 'not_connected' ? 'Google連携がされていません。/tasks/calendar/ で連携してください。' : (data.error || '配置に失敗しました'));
      } else {
        setResult({ placed: data.placed || [], unplaced: data.unplaced || [] });
        if (onScheduled) onScheduled({ placed: data.placed || [], unplaced: data.unplaced || [] });
      }
    } catch (e) {
      setError(String(e));
    } finally {
      setLoading(false);
    }
  };

  if (!open) return null;

  return (
    <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
      <div
        className="bg-bg2 border border-border rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
        onClick={(e) => e.stopPropagation()}
      >
        {/* Header */}
        <div className="flex items-start justify-between gap-3 p-5 border-b border-border">
          <div className="flex items-center gap-3">
            <div className="w-10 h-10 rounded-xl bg-blue-500/20 border border-blue-500/40 flex items-center justify-center">
              <Calendar size={18} className="text-blue-300" />
            </div>
            <div>
              <h2 className="text-base font-bold text-text">カレンダーに時間割を組む</h2>
              <p className="text-xs text-text2 mt-0.5">各タスクの想定時間を確認して、空き時間に自動配置します</p>
            </div>
          </div>
          <button onClick={onClose} className="shrink-0 p-1 rounded hover:bg-bg3 text-text3 hover:text-text">
            <X size={18} />
          </button>
        </div>

        {result ? (
          // 配置結果表示
          <div className="p-5 space-y-4">
            {result.placed.length > 0 && (
              <div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4">
                <p className="text-sm font-bold text-emerald-200 flex items-center gap-2 mb-2">
                  <Check size={14} /> {result.placed.length} 件をカレンダーに配置
                </p>
                <ul className="space-y-1 text-xs">
                  {result.placed.map((p, i) => (
                    <li key={i} className="flex items-center gap-2 text-emerald-100">
                      <span className="text-emerald-300 font-mono">{new Date(p.start).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Tokyo' })}-{new Date(p.end).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Tokyo' })}</span>
                      <span>{p.name}</span>
                    </li>
                  ))}
                </ul>
              </div>
            )}
            {result.unplaced.length > 0 && (
              <div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
                <p className="text-sm font-bold text-amber-200 flex items-center gap-2 mb-2">
                  <AlertTriangle size={14} /> {result.unplaced.length} 件が配置できませんでした
                </p>
                <ul className="space-y-1 text-xs">
                  {result.unplaced.map((u, i) => (
                    <li key={i} className="text-amber-100">• {u.name} <span className="text-amber-300/80">({u.reason})</span></li>
                  ))}
                </ul>
                <p className="text-[11px] text-amber-200/70 mt-2">業務時間を延ばすか、タスクの想定時間を減らしてもう一度試してください。</p>
              </div>
            )}
            <div className="flex justify-end">
              <button onClick={onClose} className="px-4 py-2 text-xs font-semibold rounded-lg text-white" style={{ background: ACCENT }}>
                閉じる
              </button>
            </div>
          </div>
        ) : (
          <>
            {/* Body */}
            <div className="p-5 space-y-4">
              {/* 業務時間 & バッファ設定 */}
              <div className="grid grid-cols-3 gap-3">
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">開始</label>
                  <input type="time" value={workStart} onChange={e => setWorkStart(e.target.value)} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">終了</label>
                  <input type="time" value={workEnd} onChange={e => setWorkEnd(e.target.value)} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">間のバッファ</label>
                  <input type="number" min={0} max={60} value={bufferMinutes} onChange={e => setBufferMinutes(Math.max(0, Math.min(60, Number(e.target.value) || 0)))} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
              </div>

              {/* 合計 */}
              <div className={`rounded-xl p-3 border ${overBudget ? 'bg-red-500/10 border-red-500/40' : 'bg-bg3/50 border-border'}`}>
                <div className="flex items-center justify-between">
                  <div className="flex items-center gap-2">
                    <Clock size={14} className={overBudget ? 'text-red-300' : 'text-text3'} />
                    <p className={`text-xs ${overBudget ? 'text-red-200 font-semibold' : 'text-text2'}`}>
                      合計 <span className="font-bold">{fmtMinutes(grandTotal)}</span>
                      <span className="text-text3 mx-1">/</span>
                      業務時間 <span className="font-bold">{fmtMinutes(workTotalMin)}</span>
                    </p>
                  </div>
                  <button onClick={aiReEstimate} className="text-[10px] text-blue-400 hover:underline flex items-center gap-1">
                    <Sparkles size={10} /> AI推定し直す
                  </button>
                </div>
                {overBudget && (
                  <p className="text-[11px] text-red-300 mt-1">
                    業務時間を超えています({fmtMinutes(grandTotal - workTotalMin)} オーバー)。配置できないタスクが出ます。
                  </p>
                )}
              </div>

              {/* 一括設定 */}
              <div className="flex items-center gap-2 flex-wrap">
                <span className="text-[11px] text-text3">全タスクに:</span>
                {[15, 30, 60].map(m => (
                  <button key={m} onClick={() => applyAll(m)} className="px-2 py-0.5 text-[11px] rounded-md border border-border bg-bg3 text-text2 hover:text-text">
                    {fmtMinutes(m)}
                  </button>
                ))}
              </div>

              {/* タスクリスト */}
              <div className="space-y-1.5">
                {tasks.map(t => {
                  const mins = estimates[t.key] || 30;
                  const reason = estimateTaskMinutes(t.name).reason;
                  return (
                    <div key={t.key} className="bg-bg3/40 border border-border rounded-lg p-3">
                      <div className="flex items-start gap-3 mb-2">
                        <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded border shrink-0 mt-0.5 ${
                          t.kind === 'base' ? 'bg-violet-500/15 text-violet-300 border-violet-500/40'
                          : t.kind === 'routine' ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/40'
                          : 'bg-amber-500/15 text-amber-300 border-amber-500/40'
                        }`}>
                          {t.kind === 'base' ? 'シート' : t.kind === 'routine' ? '定例' : 'メモ'}
                        </span>
                        <p className="flex-1 text-sm text-text break-words">{t.name}</p>
                      </div>
                      <div className="flex items-center gap-1.5 flex-wrap pl-1">
                        <input
                          type="number"
                          value={mins}
                          onChange={e => setMin(t.key, Number(e.target.value) || 0)}
                          min={5}
                          max={480}
                          step={5}
                          className="w-16 bg-bg3 border border-border rounded px-1.5 py-0.5 text-xs text-text text-right focus:outline-none"
                        />
                        <span className="text-[10px] text-text3">分</span>
                        {TIME_PRESETS.map(p => (
                          <button
                            key={p}
                            onClick={() => setMin(t.key, p)}
                            className={`px-1.5 py-0.5 text-[10px] rounded border transition-colors ${mins === p ? 'bg-blue-500 border-blue-500 text-white' : 'bg-bg3 border-border text-text3 hover:text-text'}`}
                          >
                            {fmtMinutes(p)}
                          </button>
                        ))}
                        <span className="text-[10px] text-text3 ml-1">推定: {reason}</span>
                      </div>
                    </div>
                  );
                })}
              </div>

              {error && (
                <div className="bg-red-500/10 border border-red-500/40 rounded-lg p-3 text-xs text-red-300">
                  {error}
                </div>
              )}
            </div>

            {/* Footer */}
            <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-bg3/30">
              <p className="text-[11px] text-text3">
                既にあるGoogleカレンダーの予定は触らず、空き時間にだけ配置します
              </p>
              <div className="flex items-center gap-2">
                <button onClick={onClose} className="px-3 py-1.5 text-xs rounded-lg bg-bg3 border border-border text-text2 hover:text-text">
                  キャンセル
                </button>
                <button
                  onClick={handleSchedule}
                  disabled={loading || tasks.length === 0}
                  className="flex items-center gap-1.5 px-4 py-2 text-xs font-bold rounded-lg text-white disabled:opacity-50"
                  style={{ background: ACCENT }}
                >
                  {loading ? '配置中...' : `${tasks.length}件をカレンダーに配置 →`}
                </button>
              </div>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理
  • スケジューリング

スケジュールモーダル

:LiTarget: 用途

タスクをカレンダーに配置するスケジュールUI。

:LiSparkle: 特徴

  • カレンダー連携
  • 時間枠選択
  • ドラッグ操作

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

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

'use client';

// 朝日報で宣言したタスクを Google カレンダーに自動配置するモーダル。
// 各タスクの想定時間を個別設定できる(キーワード推定ベースで自動プリセット、ユーザー上書き可)。

import { useState, useMemo, useEffect } from 'react';
import { X, Clock, Calendar, Sparkles, Check, AlertTriangle } from 'lucide-react';
import { estimateTaskMinutes, fmtMinutes, TIME_PRESETS } from '@/lib/task-estimate';

export interface ScheduleTask {
  key: string;                      // 一意ID(重複排除用)
  name: string;
  kind: 'base' | 'routine' | 'memo';
  taskId?: string;                  // SCALE Base 側のタスクID(紐付け用)
  priority?: number;
}

export interface ScheduleModalProps {
  open: boolean;
  onClose: () => void;
  tasks: ScheduleTask[];            // 配置したいタスク(朝日報から)
  date?: string;                    // YYYY-MM-DD (省略時は今日)
  onScheduled?: (result: { placed: any[]; unplaced: any[] }) => void;
}

const ACCENT = '#3b82f6';

export default function ScheduleModal({ open, onClose, tasks, date, onScheduled }: ScheduleModalProps) {
  const [workStart, setWorkStart] = useState('09:00');
  const [workEnd, setWorkEnd] = useState('18:00');
  const [bufferMinutes, setBufferMinutes] = useState(5);
  // タスクごとの想定時間(key → minutes)
  const [estimates, setEstimates] = useState<Record<string, number>>({});
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<{ placed: any[]; unplaced: any[] } | null>(null);
  const [error, setError] = useState<string>('');

  // 初期化: タスク名から推定
  useEffect(() => {
    if (!open) return;
    const init: Record<string, number> = {};
    const reasons: Record<string, string> = {};
    tasks.forEach(t => {
      const est = estimateTaskMinutes(t.name);
      init[t.key] = est.minutes;
      reasons[t.key] = est.reason;
    });
    setEstimates(init);
    setResult(null);
    setError('');
  }, [open, tasks]);

  const totalMinutes = useMemo(() => tasks.reduce((s, t) => s + (estimates[t.key] || 0), 0), [tasks, estimates]);
  const bufferTotal = tasks.length > 1 ? (tasks.length - 1) * bufferMinutes : 0;
  const grandTotal = totalMinutes + bufferTotal;

  // 業務時間の長さ
  const workTotalMin = useMemo(() => {
    const [sh, sm] = workStart.split(':').map(Number);
    const [eh, em] = workEnd.split(':').map(Number);
    return (eh * 60 + em) - (sh * 60 + sm);
  }, [workStart, workEnd]);

  const overBudget = grandTotal > workTotalMin;

  const setMin = (key: string, m: number) => {
    setEstimates(prev => ({ ...prev, [key]: Math.max(5, m) }));
  };

  const applyAll = (m: number) => {
    const next: Record<string, number> = {};
    tasks.forEach(t => { next[t.key] = m; });
    setEstimates(next);
  };

  const aiReEstimate = () => {
    const next: Record<string, number> = {};
    tasks.forEach(t => { next[t.key] = estimateTaskMinutes(t.name).minutes; });
    setEstimates(next);
  };

  const handleSchedule = async () => {
    setLoading(true);
    setError('');
    setResult(null);
    try {
      const payload = {
        date: date || new Date().toISOString().slice(0, 10),
        workStart, workEnd, bufferMinutes,
        tasks: tasks.map(t => ({
          name: t.name,
          estimatedMinutes: estimates[t.key] || 30,
          priority: t.priority || 3,
          taskId: t.taskId,
        })),
      };
      const res = await fetch('/api/google-calendar/auto-schedule', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error === 'not_connected' ? 'Google連携がされていません。/tasks/calendar/ で連携してください。' : (data.error || '配置に失敗しました'));
      } else {
        setResult({ placed: data.placed || [], unplaced: data.unplaced || [] });
        if (onScheduled) onScheduled({ placed: data.placed || [], unplaced: data.unplaced || [] });
      }
    } catch (e) {
      setError(String(e));
    } finally {
      setLoading(false);
    }
  };

  if (!open) return null;

  return (
    <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
      <div
        className="bg-bg2 border border-border rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
        onClick={(e) => e.stopPropagation()}
      >
        {/* Header */}
        <div className="flex items-start justify-between gap-3 p-5 border-b border-border">
          <div className="flex items-center gap-3">
            <div className="w-10 h-10 rounded-xl bg-blue-500/20 border border-blue-500/40 flex items-center justify-center">
              <Calendar size={18} className="text-blue-300" />
            </div>
            <div>
              <h2 className="text-base font-bold text-text">カレンダーに時間割を組む</h2>
              <p className="text-xs text-text2 mt-0.5">各タスクの想定時間を確認して、空き時間に自動配置します</p>
            </div>
          </div>
          <button onClick={onClose} className="shrink-0 p-1 rounded hover:bg-bg3 text-text3 hover:text-text">
            <X size={18} />
          </button>
        </div>

        {result ? (
          // 配置結果表示
          <div className="p-5 space-y-4">
            {result.placed.length > 0 && (
              <div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4">
                <p className="text-sm font-bold text-emerald-200 flex items-center gap-2 mb-2">
                  <Check size={14} /> {result.placed.length} 件をカレンダーに配置
                </p>
                <ul className="space-y-1 text-xs">
                  {result.placed.map((p, i) => (
                    <li key={i} className="flex items-center gap-2 text-emerald-100">
                      <span className="text-emerald-300 font-mono">{new Date(p.start).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Tokyo' })}-{new Date(p.end).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Tokyo' })}</span>
                      <span>{p.name}</span>
                    </li>
                  ))}
                </ul>
              </div>
            )}
            {result.unplaced.length > 0 && (
              <div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
                <p className="text-sm font-bold text-amber-200 flex items-center gap-2 mb-2">
                  <AlertTriangle size={14} /> {result.unplaced.length} 件が配置できませんでした
                </p>
                <ul className="space-y-1 text-xs">
                  {result.unplaced.map((u, i) => (
                    <li key={i} className="text-amber-100">• {u.name} <span className="text-amber-300/80">({u.reason})</span></li>
                  ))}
                </ul>
                <p className="text-[11px] text-amber-200/70 mt-2">業務時間を延ばすか、タスクの想定時間を減らしてもう一度試してください。</p>
              </div>
            )}
            <div className="flex justify-end">
              <button onClick={onClose} className="px-4 py-2 text-xs font-semibold rounded-lg text-white" style={{ background: ACCENT }}>
                閉じる
              </button>
            </div>
          </div>
        ) : (
          <>
            {/* Body */}
            <div className="p-5 space-y-4">
              {/* 業務時間 & バッファ設定 */}
              <div className="grid grid-cols-3 gap-3">
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">開始</label>
                  <input type="time" value={workStart} onChange={e => setWorkStart(e.target.value)} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">終了</label>
                  <input type="time" value={workEnd} onChange={e => setWorkEnd(e.target.value)} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
                <div>
                  <label className="text-[10px] text-text3 font-semibold block mb-1">間のバッファ</label>
                  <input type="number" min={0} max={60} value={bufferMinutes} onChange={e => setBufferMinutes(Math.max(0, Math.min(60, Number(e.target.value) || 0)))} className="w-full bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none" />
                </div>
              </div>

              {/* 合計 */}
              <div className={`rounded-xl p-3 border ${overBudget ? 'bg-red-500/10 border-red-500/40' : 'bg-bg3/50 border-border'}`}>
                <div className="flex items-center justify-between">
                  <div className="flex items-center gap-2">
                    <Clock size={14} className={overBudget ? 'text-red-300' : 'text-text3'} />
                    <p className={`text-xs ${overBudget ? 'text-red-200 font-semibold' : 'text-text2'}`}>
                      合計 <span className="font-bold">{fmtMinutes(grandTotal)}</span>
                      <span className="text-text3 mx-1">/</span>
                      業務時間 <span className="font-bold">{fmtMinutes(workTotalMin)}</span>
                    </p>
                  </div>
                  <button onClick={aiReEstimate} className="text-[10px] text-blue-400 hover:underline flex items-center gap-1">
                    <Sparkles size={10} /> AI推定し直す
                  </button>
                </div>
                {overBudget && (
                  <p className="text-[11px] text-red-300 mt-1">
                    業務時間を超えています({fmtMinutes(grandTotal - workTotalMin)} オーバー)。配置できないタスクが出ます。
                  </p>
                )}
              </div>

              {/* 一括設定 */}
              <div className="flex items-center gap-2 flex-wrap">
                <span className="text-[11px] text-text3">全タスクに:</span>
                {[15, 30, 60].map(m => (
                  <button key={m} onClick={() => applyAll(m)} className="px-2 py-0.5 text-[11px] rounded-md border border-border bg-bg3 text-text2 hover:text-text">
                    {fmtMinutes(m)}
                  </button>
                ))}
              </div>

              {/* タスクリスト */}
              <div className="space-y-1.5">
                {tasks.map(t => {
                  const mins = estimates[t.key] || 30;
                  const reason = estimateTaskMinutes(t.name).reason;
                  return (
                    <div key={t.key} className="bg-bg3/40 border border-border rounded-lg p-3">
                      <div className="flex items-start gap-3 mb-2">
                        <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded border shrink-0 mt-0.5 ${
                          t.kind === 'base' ? 'bg-violet-500/15 text-violet-300 border-violet-500/40'
                          : t.kind === 'routine' ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/40'
                          : 'bg-amber-500/15 text-amber-300 border-amber-500/40'
                        }`}>
                          {t.kind === 'base' ? 'シート' : t.kind === 'routine' ? '定例' : 'メモ'}
                        </span>
                        <p className="flex-1 text-sm text-text break-words">{t.name}</p>
                      </div>
                      <div className="flex items-center gap-1.5 flex-wrap pl-1">
                        <input
                          type="number"
                          value={mins}
                          onChange={e => setMin(t.key, Number(e.target.value) || 0)}
                          min={5}
                          max={480}
                          step={5}
                          className="w-16 bg-bg3 border border-border rounded px-1.5 py-0.5 text-xs text-text text-right focus:outline-none"
                        />
                        <span className="text-[10px] text-text3">分</span>
                        {TIME_PRESETS.map(p => (
                          <button
                            key={p}
                            onClick={() => setMin(t.key, p)}
                            className={`px-1.5 py-0.5 text-[10px] rounded border transition-colors ${mins === p ? 'bg-blue-500 border-blue-500 text-white' : 'bg-bg3 border-border text-text3 hover:text-text'}`}
                          >
                            {fmtMinutes(p)}
                          </button>
                        ))}
                        <span className="text-[10px] text-text3 ml-1">推定: {reason}</span>
                      </div>
                    </div>
                  );
                })}
              </div>

              {error && (
                <div className="bg-red-500/10 border border-red-500/40 rounded-lg p-3 text-xs text-red-300">
                  {error}
                </div>
              )}
            </div>

            {/* Footer */}
            <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-bg3/30">
              <p className="text-[11px] text-text3">
                既にあるGoogleカレンダーの予定は触らず、空き時間にだけ配置します
              </p>
              <div className="flex items-center gap-2">
                <button onClick={onClose} className="px-3 py-1.5 text-xs rounded-lg bg-bg3 border border-border text-text2 hover:text-text">
                  キャンセル
                </button>
                <button
                  onClick={handleSchedule}
                  disabled={loading || tasks.length === 0}
                  className="flex items-center gap-1.5 px-4 py-2 text-xs font-bold rounded-lg text-white disabled:opacity-50"
                  style={{ background: ACCENT }}
                >
                  {loading ? '配置中...' : `${tasks.length}件をカレンダーに配置 →`}
                </button>
              </div>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

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

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

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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