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> = { 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(() => { 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 (
{t('rail.heading')}
{isLoading &&

{t('common:loading')}

} {isError &&

{t('rail.fetchError')}

} {groups.map((g, i) => (
0 ? 'mt-2 border-t border-hairline pt-2' : ''}`} style={{ borderLeftColor: g.band }} >
{g.label}
{g.spaces.map(s => ( ))}
))} {!isLoading && !isError && sorted.length === 0 && (

{t('rail.empty')}

)}
{showCreate && ( setShowCreate(false)} onSaved={(id) => { setShowCreate(false); onSelect(id); }} /> )}
); } 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 ( ); }