211 lines
7.6 KiB
TypeScript
211 lines
7.6 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
import { LocalTask } from '../../api';
|
|
import { matchText } from '../../lib/utils';
|
|
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
|
|
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
|
|
import { FilterBar } from './FilterBar';
|
|
import { LocalTaskListItem } from './TaskListItem';
|
|
import { RailPanel } from './RailPanel';
|
|
|
|
interface TaskListPanelProps {
|
|
localTasks: LocalTask[];
|
|
selectedStatus: 'all' | StatusColumn;
|
|
sortMode: SortMode;
|
|
searchQuery: string;
|
|
activeTaskId: number | null;
|
|
onStatusChange: (status: 'all' | StatusColumn) => void;
|
|
onSortChange: (sort: SortMode) => void;
|
|
onSearchChange: (q: string) => void;
|
|
onSelectTask: (id: number) => void;
|
|
onOpenCreate: () => void;
|
|
/**
|
|
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
|
|
* on and the viewer known; otherwise everything is owner 'local' and the
|
|
* control is hidden.
|
|
*/
|
|
scope?: TaskScope;
|
|
onScopeChange?: (scope: TaskScope) => void;
|
|
currentUserId?: string | null;
|
|
scopeEnabled?: boolean;
|
|
/** 'rail' 時は RailPanel を render する。default 'list'。 */
|
|
mode?: 'list' | 'rail';
|
|
/** rail mode 時の「リストに戻る」ボタンで呼ばれる。 */
|
|
onExitFocused?: () => void;
|
|
}
|
|
|
|
function ScopeToggle({
|
|
scope,
|
|
mineCount,
|
|
allCount,
|
|
onScopeChange,
|
|
}: {
|
|
scope: TaskScope;
|
|
mineCount: number;
|
|
allCount: number;
|
|
onScopeChange: (scope: TaskScope) => void;
|
|
}) {
|
|
const { t } = useTranslation('list');
|
|
const seg = (value: TaskScope, label: string, count: number) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => onScopeChange(value)}
|
|
aria-pressed={scope === value}
|
|
className={`flex-1 px-2 py-1 rounded text-[11px] font-medium transition-colors tabular-nums ${
|
|
scope === value
|
|
? 'bg-surface text-slate-900 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{label} <span className={scope === value ? 'text-slate-500' : 'text-slate-400'}>{count}</span>
|
|
</button>
|
|
);
|
|
return (
|
|
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
|
|
{seg('mine', t('scope.mine'), mineCount)}
|
|
{seg('all', t('scope.all'), allCount)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TaskListPanel({
|
|
localTasks: allTasks,
|
|
selectedStatus,
|
|
sortMode,
|
|
searchQuery,
|
|
activeTaskId,
|
|
onStatusChange,
|
|
onSortChange,
|
|
onSearchChange,
|
|
onSelectTask,
|
|
onOpenCreate,
|
|
scope = 'mine',
|
|
onScopeChange,
|
|
currentUserId = null,
|
|
scopeEnabled = false,
|
|
mode = 'list',
|
|
onExitFocused,
|
|
}: TaskListPanelProps) {
|
|
const { t } = useTranslation('list');
|
|
// Owner scope is the outermost filter: status counts / search / sort all
|
|
// operate on the scoped list so "自分" mode never counts others' tasks.
|
|
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
|
|
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
|
|
if (mode === 'rail') {
|
|
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
|
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
|
return acc;
|
|
}, {} as Record<string, LocalTask[]>);
|
|
const allRail = Object.values(localColumnsRail).flat();
|
|
const baseRail = selectedStatus === 'all' ? allRail : localColumnsRail[selectedStatus] ?? [];
|
|
const filteredRail = baseRail.filter(t =>
|
|
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery),
|
|
);
|
|
return (
|
|
<RailPanel
|
|
tasks={filteredRail}
|
|
activeTaskId={activeTaskId}
|
|
onSelectTask={onSelectTask}
|
|
onOpenCreate={onOpenCreate}
|
|
onExitFocused={onExitFocused ?? (() => {})}
|
|
/>
|
|
);
|
|
}
|
|
const localColumns: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
|
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
|
return acc;
|
|
}, {} as Record<string, LocalTask[]>);
|
|
|
|
const allLocalTasks = Object.values(localColumns).flat();
|
|
|
|
const baseList: LocalTask[] =
|
|
selectedStatus === 'all' ? allLocalTasks : localColumns[selectedStatus] ?? [];
|
|
|
|
const filtered = baseList.filter(t =>
|
|
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery)
|
|
).sort((a, b) => {
|
|
if (sortMode === 'title') {
|
|
return a.title.localeCompare(b.title);
|
|
}
|
|
if (sortMode === 'status') {
|
|
const aStatus = a.latestJob?.status ?? 'queued';
|
|
const bStatus = b.latestJob?.status ?? 'queued';
|
|
return aStatus.localeCompare(bStatus);
|
|
}
|
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
});
|
|
|
|
const counts: Record<string, number> = {};
|
|
for (const s of COLUMN_LIST) {
|
|
counts[s] = localColumns[s]?.length ?? 0;
|
|
}
|
|
const totalCount = allLocalTasks.length;
|
|
const runningCount = counts.running ?? 0;
|
|
const waitingCount = (counts.waiting_human ?? 0) + (counts.waiting_subtasks ?? 0);
|
|
const failedCount = counts.failed ?? 0;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={onOpenCreate}
|
|
className="w-full mb-3 px-3 py-2 bg-accent hover:bg-accent-deep active:scale-[0.98] active:bg-accent-deep text-accent-fg rounded-md text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-[transform,background-color,color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
|
>
|
|
<svg
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2.25}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
{t('newRequest')}
|
|
</button>
|
|
{scopeEnabled && onScopeChange && (
|
|
<ScopeToggle
|
|
scope={scope}
|
|
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
|
|
allCount={allTasks.length}
|
|
onScopeChange={onScopeChange}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
|
|
<span><span className="font-semibold text-slate-700">{totalCount}</span> {t('counts.items')}</span>
|
|
<span aria-hidden="true" className="text-slate-300">·</span>
|
|
<span><span className="font-semibold text-emerald-600">{runningCount}</span> {t('counts.running')}</span>
|
|
<span><span className="font-semibold text-amber-600">{waitingCount}</span> {t('counts.waiting')}</span>
|
|
{failedCount > 0 && (
|
|
<span><span className="font-semibold text-red-600">{failedCount}</span> {t('counts.failed')}</span>
|
|
)}
|
|
</div>
|
|
<FilterBar
|
|
selectedStatus={selectedStatus}
|
|
sortMode={sortMode}
|
|
searchQuery={searchQuery}
|
|
counts={counts}
|
|
totalCount={totalCount}
|
|
onStatusChange={onStatusChange}
|
|
onSortChange={onSortChange}
|
|
onSearchChange={onSearchChange}
|
|
/>
|
|
<div className="flex flex-col gap-1.5 mt-2 overflow-y-auto flex-1 min-h-0 pr-0.5">
|
|
{filtered.map(task => (
|
|
<LocalTaskListItem
|
|
key={task.id}
|
|
task={task}
|
|
active={activeTaskId === task.id}
|
|
onClick={() => onSelectTask(task.id)}
|
|
/>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<div className="text-[13px] text-slate-500 px-2 py-3">{t('empty')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|