スケジュールモーダル
: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: 注意事項
- 依存パッケージを忘れず追加