188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|