maestro/ui/src/components/spaces/SpaceRail.tsx
oss-sync a6d1879d63
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (c764df2f)
2026-06-29 01:32:25 +00:00

188 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState } from '../../App';
import { useSpaces } from '../../hooks/useSpaces';
import { useLocalTaskList } from '../../hooks/useTaskList';
import { sortSpacesForRail } from '../../lib/spaceSort';
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
import { statusTone } from '../../lib/utils';
import type { Space } from '../../api';
import { SpaceFormDialog } from './SpaceFormDialog';
interface SpaceRailProps {
selectedId?: string;
onSelect: (id: string) => void;
}
// 可視性ラベルは private を出さない既定値でイズになるため。org/public のみ
// 意味があるので表示する。
const VIS_LABEL: Partial<Record<Space['visibility'], string>> = {
org: 'org',
public: 'public',
};
// グループの色帯。統合スペース(個人=作業が集まる中心)はブランド色、個別スペース
// (案件=プロジェクトごとに分かれる)は中立色で、左端の帯で一目で区別する。
const GROUP_BAND_INTEGRATED = 'var(--brand-primary)';
const GROUP_BAND_INDIVIDUAL = '#94a3b8'; // slate-400
interface SpaceGroupDef {
/** Stable key used for data-group (test selector); decoupled from the display label. */
key: string;
label: string;
band: string;
spaces: Space[];
}
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const { t } = useTranslation('spaces');
const { data: spaces, isLoading, isError } = useSpaces();
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
const { data: tasks } = useLocalTaskList();
const [showCreate, setShowCreate] = useState(false);
const auth = useAuthState();
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
// 他ユーザー所有のスペースが一覧に混在するときadmin が全ユーザーのスペースを
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
// イズにしかならず、出さないissue #003
const hasOthersSpaces = useMemo(
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
[sorted, myUserId],
);
const groups = useMemo<SpaceGroupDef[]>(() => {
const personal = sorted.filter(s => s.kind === 'personal');
const cases = sorted.filter(s => s.kind !== 'personal');
const out: SpaceGroupDef[] = [];
if (personal.length > 0) out.push({ key: '統合スペース', label: t('rail.group.integrated'), band: GROUP_BAND_INTEGRATED, spaces: personal });
if (cases.length > 0) out.push({ key: '個別スペース', label: t('rail.group.individual'), band: GROUP_BAND_INDIVIDUAL, spaces: cases });
return out;
}, [sorted, t]);
return (
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('rail.heading')}</span>
<button
type="button"
data-testid="create-space-btn"
onClick={() => setShowCreate(true)}
className="rounded-md border border-hairline px-2 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
{t('rail.new')}
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">{t('common:loading')}</p>}
{isError && <p className="px-1 py-2 text-xs text-red-600">{t('rail.fetchError')}</p>}
{groups.map((g, i) => (
<section
key={g.key}
data-testid="space-group"
data-group={g.key}
className={`border-l-2 pl-2 ${i > 0 ? 'mt-2 border-t border-hairline pt-2' : ''}`}
style={{ borderLeftColor: g.band }}
>
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
{g.spaces.map(s => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
/>
))}
</section>
))}
{!isLoading && !isError && sorted.length === 0 && (
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
)}
</div>
{showCreate && (
<SpaceFormDialog
onClose={() => setShowCreate(false)}
onSaved={(id) => {
setShowCreate(false);
onSelect(id);
}}
/>
)}
</div>
);
}
function SpaceRow({
space,
active,
onSelect,
runningCount,
mine,
}: {
space: Space;
active: boolean;
onSelect: (id: string) => void;
runningCount: number;
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
mine?: boolean;
}) {
const { t } = useTranslation('spaces');
const dot = space.brandColor ?? 'var(--brand-primary)';
const runningStyle = statusTone('running');
return (
<button
type="button"
data-testid="space-row"
data-space-kind={space.kind}
data-space-id={space.id}
data-space-mine={mine ? '1' : undefined}
onClick={() => onSelect(space.id)}
className={`mb-0.5 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors ${
active
? 'border-hairline bg-[var(--brand-primary-soft)]'
: 'border-transparent hover:bg-surface-2'
}`}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: dot }}
aria-hidden
/>
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
{mine && (
<span
data-testid="space-mine-badge"
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
title={t('rail.mineTitle')}
>
{t('rail.mine')}
</span>
)}
{VIS_LABEL[space.visibility] && (
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
{VIS_LABEL[space.visibility]}
</span>
)}
{runningCount > 0 && (
<span
data-testid="space-running-count"
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
style={{ background: runningStyle.bg, color: runningStyle.fg }}
title={t('rail.runningTitle', { count: runningCount })}
aria-label={t('rail.runningAria', { count: runningCount })}
>
{t('rail.running', { count: runningCount })}
</span>
)}
</button>
);
}