882 lines
24 KiB
TypeScript
882 lines
24 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
|
|
import type {
|
|
CreateScheduledTaskInput,
|
|
DashboardTask,
|
|
DashboardTaskAction,
|
|
DashboardTaskContextMode,
|
|
DashboardTaskScheduleType,
|
|
UpdateScheduledTaskInput,
|
|
} from './api';
|
|
import { EmptyState } from './EmptyState';
|
|
import { localeTags, type Locale, type Messages } from './i18n';
|
|
import { redactSecretsForPreview } from './redaction';
|
|
import { statusLabel } from './dashboardHelpers';
|
|
import { TaskActionButtons, type TaskActionKey } from './TaskActionButtons';
|
|
import './TaskPanel.css';
|
|
|
|
export type { TaskActionKey } from './TaskActionButtons';
|
|
|
|
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
|
type TaskFilterKey = TaskGroupKey | 'all';
|
|
type TaskResultTone = 'ok' | 'fail' | 'none';
|
|
|
|
export interface RoomOption {
|
|
jid: string;
|
|
name: string;
|
|
folder: string;
|
|
}
|
|
|
|
export interface TaskPanelProps {
|
|
tasks: DashboardTask[];
|
|
rooms: RoomOption[];
|
|
locale: Locale;
|
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
|
onTaskCreate: (input: CreateScheduledTaskInput) => void;
|
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
|
taskActionKey: TaskActionKey | null;
|
|
t: Messages;
|
|
}
|
|
|
|
interface TaskGroup {
|
|
key: TaskGroupKey;
|
|
tasks: DashboardTask[];
|
|
}
|
|
|
|
interface TaskSummary {
|
|
completed: number;
|
|
nextTask: DashboardTask | null;
|
|
paused: number;
|
|
scheduled: number;
|
|
total: number;
|
|
watchers: number;
|
|
}
|
|
|
|
interface TaskCreateFormProps {
|
|
rooms: RoomOption[];
|
|
onTaskCreate: (input: CreateScheduledTaskInput) => void;
|
|
taskActionKey: TaskActionKey | null;
|
|
t: Messages;
|
|
}
|
|
|
|
interface TaskGroupSectionProps {
|
|
group: TaskGroup;
|
|
locale: Locale;
|
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
|
taskActionKey: TaskActionKey | null;
|
|
t: Messages;
|
|
}
|
|
|
|
interface TaskCardProps {
|
|
groupKey: TaskGroupKey;
|
|
locale: Locale;
|
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
|
task: DashboardTask;
|
|
taskActionKey: TaskActionKey | null;
|
|
t: Messages;
|
|
}
|
|
|
|
interface TaskDateProps {
|
|
locale: Locale;
|
|
t: Messages;
|
|
task: DashboardTask;
|
|
}
|
|
|
|
interface TaskEditFormProps {
|
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
|
task: DashboardTask;
|
|
taskActionKey: TaskActionKey | null;
|
|
t: Messages;
|
|
}
|
|
|
|
const TASK_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
|
|
en: new Intl.DateTimeFormat(localeTags.en, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}),
|
|
ja: new Intl.DateTimeFormat(localeTags.ja, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}),
|
|
ko: new Intl.DateTimeFormat(localeTags.ko, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}),
|
|
zh: new Intl.DateTimeFormat(localeTags.zh, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}),
|
|
};
|
|
|
|
const EN_TASK_DATE_TIME_FORMATTER = new Intl.DateTimeFormat(localeTags.en, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const RELATIVE_TIME_FORMATTERS: Record<Locale, Intl.RelativeTimeFormat> = {
|
|
en: new Intl.RelativeTimeFormat(localeTags.en, {
|
|
numeric: 'auto',
|
|
style: 'short',
|
|
}),
|
|
ja: new Intl.RelativeTimeFormat(localeTags.ja, {
|
|
numeric: 'auto',
|
|
style: 'short',
|
|
}),
|
|
ko: new Intl.RelativeTimeFormat(localeTags.ko, {
|
|
numeric: 'auto',
|
|
style: 'short',
|
|
}),
|
|
zh: new Intl.RelativeTimeFormat(localeTags.zh, {
|
|
numeric: 'auto',
|
|
style: 'short',
|
|
}),
|
|
};
|
|
|
|
function formatTaskDate(
|
|
value: string | null | undefined,
|
|
locale: Locale,
|
|
): string {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const time = TASK_TIME_FORMATTERS[locale].format(date);
|
|
if (locale === 'ko')
|
|
return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`;
|
|
if (locale === 'ja' || locale === 'zh')
|
|
return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`;
|
|
return EN_TASK_DATE_TIME_FORMATTER.format(date);
|
|
}
|
|
|
|
function formatRelativeDate(
|
|
value: string | null | undefined,
|
|
locale: Locale,
|
|
t: Messages,
|
|
): string {
|
|
if (!value) return t.tasks.noTime;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const diffMs = date.getTime() - Date.now();
|
|
const absMs = Math.abs(diffMs);
|
|
if (absMs < 45_000) return t.tasks.now;
|
|
|
|
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
['day', 86_400_000],
|
|
['hour', 3_600_000],
|
|
['minute', 60_000],
|
|
];
|
|
const [unit, unitMs] =
|
|
units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!;
|
|
return RELATIVE_TIME_FORMATTERS[locale].format(
|
|
Math.round(diffMs / unitMs),
|
|
unit,
|
|
);
|
|
}
|
|
|
|
function safePreview(
|
|
value: string | null | undefined,
|
|
fallback: string,
|
|
): string {
|
|
const cleaned = redactSecretsForPreview(value ?? '')
|
|
.replace(/<\/?internal[^>]*>/gi, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (!cleaned) return fallback;
|
|
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
|
|
}
|
|
|
|
function taskGroupKey(task: DashboardTask): TaskGroupKey {
|
|
if (task.status === 'completed') return 'completed';
|
|
if (task.status === 'paused') return 'paused';
|
|
if (task.isWatcher) return 'watchers';
|
|
return 'scheduled';
|
|
}
|
|
|
|
function taskResultTone(task: DashboardTask): TaskResultTone {
|
|
if (!task.lastResult) return 'none';
|
|
const normalized = task.lastResult.toLowerCase();
|
|
if (
|
|
normalized.includes('fail') ||
|
|
normalized.includes('error') ||
|
|
normalized.includes('timeout') ||
|
|
normalized.includes('cancel') ||
|
|
normalized.includes('reject')
|
|
) {
|
|
return 'fail';
|
|
}
|
|
return 'ok';
|
|
}
|
|
|
|
function taskDisplayName(task: DashboardTask, t: Messages): string {
|
|
if (task.isWatcher) return t.tasks.ciWatch;
|
|
if (task.scheduleType) return task.scheduleType;
|
|
return task.id;
|
|
}
|
|
|
|
function taskHeadline(task: DashboardTask, t: Messages): string {
|
|
if (task.isWatcher && task.ciProvider) {
|
|
return `${t.tasks.ciWatch} · ${task.ciProvider}`;
|
|
}
|
|
return safePreview(task.promptPreview, taskDisplayName(task, t));
|
|
}
|
|
|
|
function taskScheduleText(task: DashboardTask, t: Messages): string {
|
|
const scheduleType =
|
|
task.scheduleType === 'cron' ||
|
|
task.scheduleType === 'interval' ||
|
|
task.scheduleType === 'once'
|
|
? t.tasks.scheduleTypes[task.scheduleType]
|
|
: task.scheduleType;
|
|
return `${scheduleType} · ${task.scheduleValue || '-'}`;
|
|
}
|
|
|
|
function isTaskScheduleType(
|
|
value: FormDataEntryValue | null,
|
|
): value is DashboardTaskScheduleType {
|
|
return value === 'cron' || value === 'interval' || value === 'once';
|
|
}
|
|
|
|
function isTaskContextMode(
|
|
value: FormDataEntryValue | null,
|
|
): value is DashboardTaskContextMode {
|
|
return value === 'group' || value === 'isolated';
|
|
}
|
|
|
|
function readRequiredText(form: FormData, name: string): string | null {
|
|
const value = form.get(name);
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function readTaskForm(
|
|
form: FormData,
|
|
includeRoom: true,
|
|
): CreateScheduledTaskInput | null;
|
|
function readTaskForm(
|
|
form: FormData,
|
|
includeRoom: false,
|
|
): UpdateScheduledTaskInput | null;
|
|
function readTaskForm(
|
|
form: FormData,
|
|
includeRoom: boolean,
|
|
): CreateScheduledTaskInput | UpdateScheduledTaskInput | null {
|
|
const prompt = readRequiredText(form, 'prompt');
|
|
const scheduleValue = readRequiredText(form, 'scheduleValue');
|
|
const scheduleTypeValue = form.get('scheduleType');
|
|
if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) {
|
|
return null;
|
|
}
|
|
const scheduleType = scheduleTypeValue;
|
|
|
|
if (!includeRoom) {
|
|
return prompt
|
|
? { prompt, scheduleType, scheduleValue }
|
|
: { scheduleType, scheduleValue };
|
|
}
|
|
|
|
if (!prompt) {
|
|
return null;
|
|
}
|
|
|
|
const roomJid = readRequiredText(form, 'roomJid');
|
|
const contextMode = form.get('contextMode');
|
|
if (!roomJid || !isTaskContextMode(contextMode)) return null;
|
|
return {
|
|
contextMode,
|
|
prompt,
|
|
roomJid,
|
|
scheduleType,
|
|
scheduleValue,
|
|
};
|
|
}
|
|
|
|
function buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] {
|
|
const groups: Record<TaskGroupKey, DashboardTask[]> = {
|
|
watchers: [],
|
|
scheduled: [],
|
|
paused: [],
|
|
completed: [],
|
|
};
|
|
|
|
for (const task of tasks) {
|
|
groups[taskGroupKey(task)].push(task);
|
|
}
|
|
|
|
for (const groupTasks of Object.values(groups)) {
|
|
groupTasks.sort((a, b) =>
|
|
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
|
|
b.nextRun ?? b.lastRun ?? b.createdAt,
|
|
),
|
|
);
|
|
}
|
|
|
|
return [
|
|
{ key: 'watchers', tasks: groups.watchers },
|
|
{ key: 'scheduled', tasks: groups.scheduled },
|
|
{ key: 'paused', tasks: groups.paused },
|
|
{ key: 'completed', tasks: groups.completed },
|
|
];
|
|
}
|
|
|
|
function buildTaskSummary(tasks: DashboardTask[]): TaskSummary {
|
|
const summary: TaskSummary = {
|
|
completed: 0,
|
|
nextTask: null,
|
|
paused: 0,
|
|
scheduled: 0,
|
|
total: tasks.length,
|
|
watchers: 0,
|
|
};
|
|
|
|
for (const task of tasks) {
|
|
const group = taskGroupKey(task);
|
|
if (group === 'watchers') summary.watchers += 1;
|
|
if (group === 'scheduled') summary.scheduled += 1;
|
|
if (group === 'paused') summary.paused += 1;
|
|
if (group === 'completed') summary.completed += 1;
|
|
|
|
if (task.status !== 'active' || !task.nextRun) continue;
|
|
const nextTime = new Date(task.nextRun).getTime();
|
|
if (Number.isNaN(nextTime)) continue;
|
|
const currentTime = summary.nextTask?.nextRun
|
|
? new Date(summary.nextTask.nextRun).getTime()
|
|
: Number.POSITIVE_INFINITY;
|
|
if (nextTime < currentTime) {
|
|
summary.nextTask = task;
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
function TaskSummaryBar({
|
|
locale,
|
|
summary,
|
|
t,
|
|
}: {
|
|
locale: Locale;
|
|
summary: TaskSummary;
|
|
t: Messages;
|
|
}) {
|
|
const nextTask = summary.nextTask;
|
|
return (
|
|
<section className="task-summary-bar" aria-label={t.panels.scheduled}>
|
|
<div className="task-summary-next">
|
|
<small>{t.tasks.next}</small>
|
|
<strong>
|
|
{nextTask ? formatRelativeDate(nextTask.nextRun, locale, t) : '-'}
|
|
</strong>
|
|
<span>
|
|
{nextTask
|
|
? taskHeadline(nextTask, t)
|
|
: summary.total > 0
|
|
? `${summary.total} ${t.tasks.count}`
|
|
: t.tasks.empty}
|
|
</span>
|
|
</div>
|
|
<div className="task-summary-chips" aria-hidden="true">
|
|
<span className="task-chip">
|
|
{t.tasks.groups.scheduled} {summary.scheduled}
|
|
</span>
|
|
<span className="task-chip">
|
|
{t.tasks.groups.watchers} {summary.watchers}
|
|
</span>
|
|
<span className="task-chip">
|
|
{t.tasks.groups.paused} {summary.paused}
|
|
</span>
|
|
<span className="task-chip">
|
|
{t.tasks.groups.completed} {summary.completed}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TaskFilterTabs({
|
|
activeFilter,
|
|
counts,
|
|
onSelect,
|
|
t,
|
|
}: {
|
|
activeFilter: TaskFilterKey;
|
|
counts: Record<TaskFilterKey, number>;
|
|
onSelect: (filter: TaskFilterKey) => void;
|
|
t: Messages;
|
|
}) {
|
|
const tabs: Array<{ key: TaskFilterKey; label: string }> = [
|
|
{ key: 'all', label: t.tasks.filterAll },
|
|
{ key: 'scheduled', label: t.tasks.groups.scheduled },
|
|
{ key: 'watchers', label: t.tasks.groups.watchers },
|
|
{ key: 'paused', label: t.tasks.groups.paused },
|
|
{ key: 'completed', label: t.tasks.groups.completed },
|
|
];
|
|
|
|
return (
|
|
<div className="task-filter-scroll">
|
|
<div
|
|
aria-label={t.panels.scheduled}
|
|
className="task-filter-tabs"
|
|
role="tablist"
|
|
>
|
|
{tabs.map((tab) => (
|
|
<button
|
|
aria-selected={activeFilter === tab.key}
|
|
className="task-filter-tab"
|
|
key={tab.key}
|
|
onClick={() => onSelect(tab.key)}
|
|
role="tab"
|
|
type="button"
|
|
>
|
|
<strong>{tab.label}</strong>
|
|
<small>{counts[tab.key]}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskCreateForm({
|
|
rooms,
|
|
onTaskCreate,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskCreateFormProps) {
|
|
return (
|
|
<details className="task-create-panel">
|
|
<summary>{t.tasks.createTitle}</summary>
|
|
<form
|
|
className="task-create-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
const input = readTaskForm(form, true);
|
|
if (!input) return;
|
|
onTaskCreate(input);
|
|
event.currentTarget.reset();
|
|
}}
|
|
>
|
|
<p className="task-create-hint">{t.tasks.scheduleValueHint}</p>
|
|
<label className="task-form-wide">
|
|
<span>{t.tasks.prompt}</span>
|
|
<textarea
|
|
name="prompt"
|
|
placeholder={t.tasks.promptPlaceholder}
|
|
required
|
|
rows={3}
|
|
/>
|
|
</label>
|
|
<div className="task-form-grid">
|
|
<label>
|
|
<span>{t.tasks.room}</span>
|
|
<select name="roomJid" required>
|
|
<option value="">{t.tasks.selectRoom}</option>
|
|
{rooms.map((room) => (
|
|
<option key={room.jid} value={room.jid}>
|
|
{room.name} · {room.folder}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{t.tasks.scheduleType}</span>
|
|
<select name="scheduleType" required>
|
|
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
|
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
|
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{t.tasks.scheduleValue}</span>
|
|
<input
|
|
name="scheduleValue"
|
|
placeholder={t.tasks.scheduleValueHint}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>{t.tasks.context}</span>
|
|
<select name="contextMode" required>
|
|
<option value="isolated">{t.tasks.contextModes.isolated}</option>
|
|
<option value="group">{t.tasks.contextModes.group}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button disabled={taskActionKey === 'create'} type="submit">
|
|
{taskActionKey === 'create'
|
|
? t.tasks.actions.busy
|
|
: t.tasks.actions.create}
|
|
</button>
|
|
</form>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
|
|
return (
|
|
<div className="task-time-grid">
|
|
<span className="task-next-run">
|
|
<small>{t.tasks.next}</small>
|
|
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
|
|
<em>{formatTaskDate(task.nextRun, locale)}</em>
|
|
</span>
|
|
<span>
|
|
<small>{t.tasks.last}</small>
|
|
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
|
|
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
|
|
</span>
|
|
<span>
|
|
<small>{t.tasks.schedule}</small>
|
|
<strong>{taskScheduleText(task, t)}</strong>
|
|
<em>{task.contextMode}</em>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskSuspendedUntil({ locale, t, task }: TaskDateProps) {
|
|
if (!task.suspendedUntil) return null;
|
|
|
|
return (
|
|
<div className="task-suspended">
|
|
<span>{t.tasks.suspendedUntil}</span>
|
|
<strong>{formatTaskDate(task.suspendedUntil, locale)}</strong>
|
|
<em>{formatRelativeDate(task.suspendedUntil, locale, t)}</em>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskResult({ task, t }: { task: DashboardTask; t: Messages }) {
|
|
const resultTone = taskResultTone(task);
|
|
const lastResult = safePreview(task.lastResult, t.tasks.noResult);
|
|
|
|
return (
|
|
<div className={`task-result result-${resultTone}`}>
|
|
<span>
|
|
{resultTone === 'fail'
|
|
? t.tasks.resultFail
|
|
: resultTone === 'ok'
|
|
? t.tasks.resultOk
|
|
: t.tasks.result}
|
|
</span>
|
|
<strong>{lastResult}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskPromptBlock({ task, t }: { task: DashboardTask; t: Messages }) {
|
|
return (
|
|
<div className="task-prompt-block">
|
|
<span>{t.tasks.prompt}</span>
|
|
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
|
|
<small>
|
|
{task.id} · {taskScheduleText(task, t)} · {task.promptLength}{' '}
|
|
{t.units.chars}
|
|
</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskMetaStrip({ task, t }: { task: DashboardTask; t: Messages }) {
|
|
return (
|
|
<div className="task-meta-strip">
|
|
<span>{task.groupFolder}</span>
|
|
<span>{task.agentType ?? t.tasks.task}</span>
|
|
{task.ciProvider ? <span>{task.ciProvider}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskEditForm({
|
|
onTaskUpdate,
|
|
task,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskEditFormProps) {
|
|
if (task.isWatcher || task.status === 'completed') return null;
|
|
|
|
return (
|
|
<details className="task-edit">
|
|
<summary>{t.tasks.actions.edit}</summary>
|
|
<form
|
|
className="task-edit-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
const input = readTaskForm(form, false);
|
|
if (!input) return;
|
|
onTaskUpdate(task, input);
|
|
}}
|
|
>
|
|
<label className="task-form-wide">
|
|
<span>{t.tasks.prompt}</span>
|
|
<textarea name="prompt" placeholder={t.tasks.editPromptPlaceholder} />
|
|
</label>
|
|
<label>
|
|
<span>{t.tasks.scheduleType}</span>
|
|
<select name="scheduleType" defaultValue={task.scheduleType} required>
|
|
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
|
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
|
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{t.tasks.scheduleValue}</span>
|
|
<input
|
|
name="scheduleValue"
|
|
defaultValue={task.scheduleValue}
|
|
required
|
|
/>
|
|
</label>
|
|
<button disabled={taskActionKey === `${task.id}:edit`} type="submit">
|
|
{taskActionKey === `${task.id}:edit`
|
|
? t.tasks.actions.busy
|
|
: t.tasks.actions.save}
|
|
</button>
|
|
</form>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function TaskCard({
|
|
groupKey,
|
|
locale,
|
|
onTaskAction,
|
|
onTaskUpdate,
|
|
task,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskCardProps) {
|
|
return (
|
|
<article className={`task-row task-row-${groupKey}`}>
|
|
<div className="task-row-head">
|
|
<div className="task-row-lead">
|
|
<span className={`pill pill-${task.status}`}>
|
|
{statusLabel(task.status, t)}
|
|
</span>
|
|
<div className="task-row-copy">
|
|
<span className="task-kind">{taskDisplayName(task, t)}</span>
|
|
<strong title={taskHeadline(task, t)}>
|
|
{taskHeadline(task, t)}
|
|
</strong>
|
|
<TaskMetaStrip task={task} t={t} />
|
|
</div>
|
|
</div>
|
|
<div className="task-row-aside">
|
|
<div className="task-row-next">
|
|
<small>{t.tasks.next}</small>
|
|
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
|
|
<em>{formatTaskDate(task.nextRun, locale)}</em>
|
|
</div>
|
|
<TaskActionButtons
|
|
className="task-row-actions"
|
|
onTaskAction={onTaskAction}
|
|
task={task}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<details className="task-row-details">
|
|
<summary>{t.tasks.details}</summary>
|
|
<div className="task-row-details-body">
|
|
<TaskTimeGrid locale={locale} t={t} task={task} />
|
|
<TaskSuspendedUntil locale={locale} t={t} task={task} />
|
|
<TaskResult task={task} t={t} />
|
|
<TaskPromptBlock task={task} t={t} />
|
|
<TaskEditForm
|
|
onTaskUpdate={onTaskUpdate}
|
|
task={task}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</details>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function TaskGroupBody({
|
|
group,
|
|
locale,
|
|
onTaskAction,
|
|
onTaskUpdate,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskGroupSectionProps) {
|
|
if (group.tasks.length === 0) {
|
|
return <div className="task-group-empty">{t.tasks.groupEmpty}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="task-list">
|
|
{group.tasks.map((task) => (
|
|
<TaskCard
|
|
groupKey={group.key}
|
|
key={task.id}
|
|
locale={locale}
|
|
onTaskAction={onTaskAction}
|
|
onTaskUpdate={onTaskUpdate}
|
|
task={task}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskGroupHead({
|
|
countLabel,
|
|
group,
|
|
label,
|
|
}: {
|
|
countLabel: string;
|
|
group: TaskGroup;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<div className="task-group-head">
|
|
<strong>
|
|
{label} · {group.tasks.length} {countLabel}
|
|
</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskGroupSection({
|
|
group,
|
|
locale,
|
|
onTaskAction,
|
|
onTaskUpdate,
|
|
showHead,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskGroupSectionProps & { showHead: boolean }) {
|
|
if (group.tasks.length === 0) return null;
|
|
|
|
const label = t.tasks.groups[group.key];
|
|
const body = (
|
|
<TaskGroupBody
|
|
group={group}
|
|
locale={locale}
|
|
onTaskAction={onTaskAction}
|
|
onTaskUpdate={onTaskUpdate}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
);
|
|
|
|
if (group.key === 'completed') {
|
|
return (
|
|
<details className="task-group task-group-completed">
|
|
<summary className="task-group-head">
|
|
<strong>{label}</strong>
|
|
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
|
|
</summary>
|
|
{body}
|
|
</details>
|
|
);
|
|
}
|
|
|
|
if (!showHead) {
|
|
return (
|
|
<section className={`task-group task-group-${group.key}`}>{body}</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className={`task-group task-group-${group.key}`}>
|
|
<TaskGroupHead countLabel={t.tasks.count} group={group} label={label} />
|
|
{body}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function buildFilterCounts(groups: TaskGroup[]): Record<TaskFilterKey, number> {
|
|
const counts: Record<TaskFilterKey, number> = {
|
|
all: 0,
|
|
watchers: 0,
|
|
scheduled: 0,
|
|
paused: 0,
|
|
completed: 0,
|
|
};
|
|
for (const group of groups) {
|
|
counts[group.key] = group.tasks.length;
|
|
counts.all += group.tasks.length;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export function TaskPanel({
|
|
tasks,
|
|
rooms,
|
|
locale,
|
|
onTaskAction,
|
|
onTaskCreate,
|
|
onTaskUpdate,
|
|
taskActionKey,
|
|
t,
|
|
}: TaskPanelProps) {
|
|
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
|
|
const taskSummary = useMemo(() => buildTaskSummary(tasks), [tasks]);
|
|
const filterCounts = useMemo(
|
|
() => buildFilterCounts(taskGroups),
|
|
[taskGroups],
|
|
);
|
|
const [activeFilter, setActiveFilter] = useState<TaskFilterKey>('all');
|
|
|
|
const visibleGroups = useMemo(() => {
|
|
if (activeFilter === 'all') {
|
|
return taskGroups.filter((group) => group.tasks.length > 0);
|
|
}
|
|
return taskGroups.filter((group) => group.key === activeFilter);
|
|
}, [activeFilter, taskGroups]);
|
|
|
|
return (
|
|
<div className="task-board" aria-label={t.tasks.cardsAria}>
|
|
<TaskSummaryBar locale={locale} summary={taskSummary} t={t} />
|
|
<TaskFilterTabs
|
|
activeFilter={activeFilter}
|
|
counts={filterCounts}
|
|
onSelect={setActiveFilter}
|
|
t={t}
|
|
/>
|
|
<TaskCreateForm
|
|
onTaskCreate={onTaskCreate}
|
|
rooms={rooms}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
|
|
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
|
|
{tasks.length > 0 && visibleGroups.length === 0 ? (
|
|
<EmptyState>{t.tasks.groupEmpty}</EmptyState>
|
|
) : null}
|
|
<div className="task-lanes">
|
|
{visibleGroups.map((group) => (
|
|
<TaskGroupSection
|
|
group={group}
|
|
key={group.key}
|
|
locale={locale}
|
|
onTaskAction={onTaskAction}
|
|
onTaskUpdate={onTaskUpdate}
|
|
showHead={activeFilter === 'all'}
|
|
taskActionKey={taskActionKey}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|