Open-source release of MAESTRO, an agent orchestration platform that runs LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0. See README.md and docs/ (getting-started, configuration, architecture).
428 lines
22 KiB
JavaScript
428 lines
22 KiB
JavaScript
// SchedulesPage — mirrors the Tasks 3-pane shell: list | detail | history
|
|
// Data model derives from ui/src/pages/SchedulesPage.tsx.
|
|
|
|
const DAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
|
|
|
function parseCronToDisplay(cron) {
|
|
if (cron === 'once') return '一回のみ';
|
|
const parts = (cron || '').split(' ');
|
|
if (parts.length !== 5) return cron;
|
|
const [min, hour, dom, , dow] = parts;
|
|
const hhmm = `${hour}:${String(min).padStart(2, '0')}`;
|
|
if (dom !== '*' && dow === '*') return `毎月${dom}日 ${hhmm}`;
|
|
if (dow !== '*' && dom === '*') return `毎週${DAYS[Number(dow)] ?? dow}曜 ${hhmm}`;
|
|
if (dom === '*' && dow === '*') return `毎日 ${hhmm}`;
|
|
return cron;
|
|
}
|
|
|
|
function formatDateShort(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return d.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function relativeFromNow(iso) {
|
|
if (!iso) return '—';
|
|
const diff = new Date(iso).getTime() - Date.now();
|
|
const abs = Math.abs(diff);
|
|
const mins = Math.round(abs / 60000);
|
|
const hrs = Math.round(mins / 60);
|
|
const days = Math.round(hrs / 24);
|
|
const unit = mins < 60 ? `${mins}分` : hrs < 24 ? `${hrs}時間` : `${days}日`;
|
|
return diff >= 0 ? `${unit}後` : `${unit}前`;
|
|
}
|
|
|
|
// ── Left: list of schedules ──────────────────────────────────────────────
|
|
function ScheduleListItem({ sch, active, onClick }) {
|
|
return (
|
|
<button onClick={onClick} style={{
|
|
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
|
|
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
|
|
background: active ? '#eff6ff' : '#fff',
|
|
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
<span style={{
|
|
width: 8, height: 8, borderRadius: 9999, flexShrink: 0,
|
|
background: sch.isActive ? '#22c55e' : '#cbd5e1',
|
|
}} />
|
|
<div style={{
|
|
flex: 1, minWidth: 0,
|
|
fontSize: 13, fontWeight: 700, color: sch.isActive ? '#0f172a' : '#64748b',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>{sch.title || 'タイトルなし'}</div>
|
|
{sch.triggerKind === 'event' && (
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 700, color: '#5b21b6', background: '#ede9fe',
|
|
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
|
|
}}>event</span>
|
|
)}
|
|
</div>
|
|
<div style={{ marginTop: 4, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{sch.triggerKind === 'event' ? sch.eventSource : parseCronToDisplay(sch.cronExpression)}
|
|
</div>
|
|
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>
|
|
{sch.isActive
|
|
? (sch.nextRunAt ? `次回 ${formatDateShort(sch.nextRunAt)} (${relativeFromNow(sch.nextRunAt)})` : '次回未定')
|
|
: '停止中'}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ScheduleListPane({ items, activeId, onSelect, filter, setFilter, search, setSearch, onOpenCreate }) {
|
|
const filtered = items.filter(s => {
|
|
if (filter === 'active' && !s.isActive) return false;
|
|
if (filter === 'paused' && s.isActive) return false;
|
|
if (filter === 'event' && s.triggerKind !== 'event') return false;
|
|
if (search && !(s.title + s.body).toLowerCase().includes(search.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
const counts = {
|
|
all: items.length,
|
|
active: items.filter(s => s.isActive).length,
|
|
paused: items.filter(s => !s.isActive).length,
|
|
event: items.filter(s => s.triggerKind === 'event').length,
|
|
};
|
|
const chipStyle = (on) => ({
|
|
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
|
|
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
|
|
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
|
|
background: on ? '#eff6ff' : '#fff',
|
|
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
|
|
});
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
<button onClick={onOpenCreate} style={{
|
|
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
|
|
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
|
|
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
|
|
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
新しいスケジュール
|
|
</button>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
|
|
color: '#64748b', padding: '0 2px 10px',
|
|
}}>
|
|
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> 件</span>
|
|
<span style={{ color: '#cbd5e1' }}>·</span>
|
|
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{counts.active}</b> 有効</span>
|
|
{counts.paused > 0 && <span><b style={{ color: '#64748b', fontWeight: 700 }}>{counts.paused}</b> 停止</span>}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
|
|
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
|
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="検索..."
|
|
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
|
|
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
|
|
<button style={chipStyle(filter === 'active')} onClick={() => setFilter('active')}>有効 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.active}</span></button>
|
|
<button style={chipStyle(filter === 'paused')} onClick={() => setFilter('paused')}>停止 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.paused}</span></button>
|
|
<button style={chipStyle(filter === 'event')} onClick={() => setFilter('event')}>Event <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.event}</span></button>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
{filtered.map(s => <ScheduleListItem key={s.id} sch={s} active={activeId === s.id} onClick={() => onSelect(s.id)} />)}
|
|
{filtered.length === 0 && (
|
|
(search || filter !== 'all') ? (
|
|
<EmptyState
|
|
compact
|
|
icon={<IconSearch width={18} height={18} />}
|
|
title="該当するスケジュールはありません"
|
|
hint="検索やフィルタを変えてみてください。"
|
|
action={
|
|
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
|
|
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
|
|
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
|
|
cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>フィルタをクリア</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
compact
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
|
|
title="スケジュールがありません"
|
|
hint="定期実行やイベントトリガーを登録するとここに表示されます。"
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Center: schedule detail editor ───────────────────────────────────────
|
|
function FormRow({ label, help, children }) {
|
|
return (
|
|
<label style={{ display: 'block', marginBottom: 14 }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#475569', marginBottom: 4 }}>{label}</div>
|
|
{children}
|
|
{help && <div style={{ fontSize: 10, color: '#94a3b8', marginTop: 4 }}>{help}</div>}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function TextInput(props) {
|
|
return <input {...props} style={{
|
|
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
|
outline: 'none', color: '#0f172a',
|
|
...(props.style || {}),
|
|
}} />;
|
|
}
|
|
|
|
function SelectInput({ children, ...props }) {
|
|
return <select {...props} style={{
|
|
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
|
outline: 'none', color: '#0f172a',
|
|
}}>{children}</select>;
|
|
}
|
|
|
|
function ScheduleDetail({ sch, onPatch, onTrigger, onDelete }) {
|
|
if (!sch) {
|
|
return (
|
|
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
|
<EmptyState
|
|
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
|
|
title="スケジュールを選択してください"
|
|
hint="左のリストから編集したいスケジュールを開きます。"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isEvent = sch.triggerKind === 'event';
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* Header */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
|
<span style={{
|
|
width: 10, height: 10, borderRadius: 9999,
|
|
background: sch.isActive ? '#22c55e' : '#cbd5e1',
|
|
}} />
|
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>SCHEDULE #{sch.id}</div>
|
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{sch.title || 'タイトルなし'}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
<button onClick={() => onTrigger(sch.id)} style={{
|
|
padding: '6px 12px', background: '#fff', border: '1px solid #bfdbfe', color: '#1d4ed8',
|
|
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
}}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
今すぐ実行
|
|
</button>
|
|
<button onClick={() => onPatch(sch.id, { isActive: !sch.isActive })} style={{
|
|
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
|
|
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>{sch.isActive ? '停止' : '再開'}</button>
|
|
<button onClick={() => onDelete(sch.id)} style={{
|
|
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
|
|
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>削除</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
|
|
<div style={{ maxWidth: 640, margin: '0 auto' }}>
|
|
{/* Summary strip */}
|
|
<div style={{
|
|
display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap',
|
|
}}>
|
|
<StatChip label="トリガー" value={isEvent ? 'Event' : 'Cron'} />
|
|
<StatChip label={isEvent ? 'ソース' : 'スケジュール'} value={isEvent ? sch.eventSource : parseCronToDisplay(sch.cronExpression)} />
|
|
<StatChip label="ピース" value={sch.pieceName} color="#2563eb" />
|
|
{sch.isActive
|
|
? <StatChip label="次回実行" value={sch.nextRunAt ? relativeFromNow(sch.nextRunAt) : '—'} />
|
|
: <StatChip label="ステータス" value="停止中" color="#64748b" />}
|
|
</div>
|
|
|
|
{/* Form card */}
|
|
<div style={{
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
|
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
|
基本情報
|
|
</div>
|
|
|
|
<FormRow label="タイトル">
|
|
<TextInput value={sch.title || ''} onChange={e => onPatch(sch.id, { title: e.target.value })} placeholder="週次ニュースまとめ" />
|
|
</FormRow>
|
|
|
|
<FormRow label="プロンプト" help="エージェントに送るメッセージ">
|
|
<textarea value={sch.body} onChange={e => onPatch(sch.id, { body: e.target.value })} rows={4} style={{
|
|
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
|
outline: 'none', color: '#0f172a', resize: 'vertical', lineHeight: 1.55,
|
|
}} />
|
|
</FormRow>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<FormRow label="ピース">
|
|
<SelectInput value={sch.pieceName} onChange={e => onPatch(sch.id, { pieceName: e.target.value })}>
|
|
<option value="auto">auto</option>
|
|
<option value="chat">chat</option>
|
|
<option value="research">research</option>
|
|
<option value="general">general</option>
|
|
<option value="x-ai-digest">x-ai-digest</option>
|
|
</SelectInput>
|
|
</FormRow>
|
|
<FormRow label="出力フォーマット">
|
|
<SelectInput value={sch.outputFormat || 'markdown'} onChange={e => onPatch(sch.id, { outputFormat: e.target.value })}>
|
|
<option value="markdown">markdown</option>
|
|
<option value="plain">plain</option>
|
|
<option value="json">json</option>
|
|
</SelectInput>
|
|
</FormRow>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Trigger card */}
|
|
<div style={{
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
|
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
|
トリガー
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
|
{['cron', 'event'].map(k => (
|
|
<button key={k} onClick={() => onPatch(sch.id, { triggerKind: k })} style={{
|
|
flex: 1, padding: '10px 12px', borderRadius: 10,
|
|
border: '1px solid ' + (sch.triggerKind === k ? '#2563eb' : '#e2e8f0'),
|
|
background: sch.triggerKind === k ? '#eff6ff' : '#fff',
|
|
color: sch.triggerKind === k ? '#1d4ed8' : '#64748b',
|
|
fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
|
|
textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 2,
|
|
}}>
|
|
<span>{k === 'cron' ? '定期実行 (Cron)' : 'イベントトリガー'}</span>
|
|
<span style={{ fontSize: 10, color: sch.triggerKind === k ? '#3b82f6' : '#94a3b8', fontWeight: 500 }}>
|
|
{k === 'cron' ? '毎日 / 毎週 / カスタム' : 'GitHub / Mail / Webhook'}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{!isEvent && (
|
|
<>
|
|
<FormRow label="Cron 式" help="分 時 日 月 曜日 · 例: 0 7 * * * = 毎日 07:00 (UTC)">
|
|
<TextInput value={sch.cronExpression} onChange={e => onPatch(sch.id, { cronExpression: e.target.value })}
|
|
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder="0 7 * * *" />
|
|
</FormRow>
|
|
<div style={{ padding: '10px 12px', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 10, fontSize: 12, color: '#475569' }}>
|
|
<b style={{ color: '#0f172a' }}>{parseCronToDisplay(sch.cronExpression)}</b>
|
|
{sch.nextRunAt && <span> · 次回 {formatDateShort(sch.nextRunAt)} ({relativeFromNow(sch.nextRunAt)})</span>}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{isEvent && (
|
|
<>
|
|
<FormRow label="イベントソース">
|
|
<SelectInput value={sch.eventSource || ''} onChange={e => onPatch(sch.id, { eventSource: e.target.value })}>
|
|
<option value="github.issue.opened">github.issue.opened</option>
|
|
<option value="github.pr.opened">github.pr.opened</option>
|
|
<option value="gitea.push">gitea.push</option>
|
|
<option value="mail.received">mail.received</option>
|
|
<option value="webhook.custom">webhook.custom</option>
|
|
</SelectInput>
|
|
</FormRow>
|
|
<FormRow label="フィルタ条件" help="該当イベントが発火した時のみ実行">
|
|
<TextInput value={sch.eventFilter || ''} onChange={e => onPatch(sch.id, { eventFilter: e.target.value })}
|
|
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder='repo == "agent-orchestrator" && label == "bug"' />
|
|
</FormRow>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* History */}
|
|
<div style={{
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
|
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14,
|
|
}}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
|
|
実行履歴
|
|
</div>
|
|
<span style={{ fontSize: 11, color: '#94a3b8' }}>直近 {sch.history?.length || 0} 件</span>
|
|
</div>
|
|
|
|
{(sch.history || []).length === 0 && (
|
|
<EmptyState
|
|
compact
|
|
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
|
|
title="まだ実行されていません"
|
|
hint={sch.isActive ? '次回の実行後にここに履歴が追加されます。' : 'スケジュールは停止中です。「再開」で有効化できます。'}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{(sch.history || []).map((h, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
|
|
borderTop: i === 0 ? 'none' : '1px solid #f1f5f9',
|
|
}}>
|
|
<StatusBadge status={h.status} small />
|
|
<div style={{ flex: 1, fontSize: 12, color: '#334155' }}>
|
|
<a href="#" style={{ color: '#2563eb', fontWeight: 700, textDecoration: 'none' }}>#{h.taskId}</a>
|
|
{' · '}{h.summary || '—'}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#94a3b8', whiteSpace: 'nowrap' }}>{formatDateShort(h.at)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ height: 40 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SchedulesPage({ schedules, activeId, setActiveId, onPatch, onTrigger, onDelete, onOpenCreate }) {
|
|
const [filter, setFilter] = React.useState('all');
|
|
const [search, setSearch] = React.useState('');
|
|
const active = schedules.find(s => s.id === activeId) || schedules[0];
|
|
return (
|
|
<div style={{
|
|
flex: 1, minHeight: 0, display: 'grid',
|
|
gridTemplateColumns: '320px 1fr',
|
|
background: '#f1f5f9', gap: 1,
|
|
}}>
|
|
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<ScheduleListPane
|
|
items={schedules} activeId={active?.id} onSelect={setActiveId}
|
|
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
|
|
onOpenCreate={onOpenCreate}
|
|
/>
|
|
</div>
|
|
<div style={{ background: '#fff', minWidth: 0 }}>
|
|
<ScheduleDetail sch={active} onPatch={onPatch} onTrigger={onTrigger} onDelete={onDelete} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.SchedulesPage = SchedulesPage;
|