workspace-ui-kit/components/workspace/Workspace.tsx
snc777 f6a781c47e initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 14:24:17 +09:00

383 lines
16 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.

"use client";
/**
* Workspace: 4 ペインの親コンポーネント。
*
* - Pane 1〜4 の statecandidates / selectedCandidateId / selectedDetail
* 保持し、各ペインに props として渡す。
* `previousDetail` state は ADR-0011 §6 大決定 D で削除した(戻り先が候詳に固定
* されたため、直前の詳細を 1 段階覚える概念が不要になった)。
* - Pane 3 = 候補者ダッシュボード(人物軸の編集: ヘッダー帯 + 採用条件 + 選考フロー)
* - Pane 4 = ステージ軸の編集(選考ステージ詳細のみ)
* ADR-0015 §9 大決定 G により、Pane 4 のデフォルト state は `null`
* (ステージ未選択 = 畳み状態)。◀ ボタンは撤廃。
*
* レイアウト構造shadcn/ui Sidebar を採用、ADR-0006 §3/§5 を本実装で改訂):
*
* ```
* <SidebarProvider> (h-screen, defaultOpen, Cmd+B でトグル)
* ┌─ Sidebar (Pane 1) ─┬─ SidebarInset ─────────────────────┐
* │ (画面最上端 │ ┌─ GlobalHeader (h-12) ─────────┐ │
* │ 〜最下端) │ └─────────────────────────────────┘ │
* │ collapsible="icon" │ ┌─ Pane 2 ─┬─ Pane 3 ─┬─ Pane 4 ─┐ │
* │ 240px ↔ 48px │ │ │ │ │ │
* └────────────────────┴─┴──────────┴──────────┴──────────┘
* ```
*
* - Pane 1 のみ画面最上端〜最下端まで届く chrome折りたたみ可
* - GlobalHeader は Pane 1 を除く右側全幅Pane 2 / Pane 3 / Pane 4 の上)に渡る
* - Pane 4 はヘッダー直下から最下端まで
* - Pane 1 折りたたみトグルは Pane 1 ヘッダー右端の `Pane1Toggle` 1 箇所
* ADR-0006 §5 で計画していた GlobalHeader 側の SidebarTrigger は本実装で撤回)
*
* 仕様の出典:
* - openspec/decision/0006-pane-background-hierarchy-and-shadcn-inset-header.md
* §24 段階背景色階層)/ §4保存ステータス削除はそのまま採用
* §3Pane 4 = 画面最上端〜最下端 / ヘッダーは中央エリアのみ)は本実装で再改訂
* §5SidebarTrigger は GlobalHeaderも本実装で再改訂Pane 1 ヘッダー側に集約)
* - openspec/decision/0009-drilldown-card-affordance.mdPane 3 ドリルダウンカードの ▶ 規律)
* - openspec/changes/add-4pane-workspace-template/specs/workspace-template/spec.md
* - openspec/changes/add-4pane-workspace-template/design.md D51〜D56 / D65
*/
import { useState, useCallback, useMemo } from "react";
import {
type Profile,
type AxisKey,
type StageKey,
type Department,
type Candidate,
type Group,
type SelectedDetail,
STAGE_ORDER,
} from "@/lib/schema";
import {
createMinimalProfile,
createMinimalScorecard,
} from "@/lib/data/factories";
import { getCandidateAverageScore } from "@/lib/computed/scorecards";
import { STAGE_LABELS } from "@/lib/labels";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { GlobalHeader } from "@/components/workspace/GlobalHeader";
import { PositionPane } from "@/components/workspace/PositionPane";
import { CandidateListPane } from "@/components/workspace/CandidateListPane";
import { CandidateDashboardPane } from "@/components/workspace/CandidateDashboardPane";
import { CandidateDetailPane } from "@/components/workspace/CandidateDetailPane";
// ========== UI 内部型 ==========
//
// 種データは `data/*.json` → Server Componentapp/page.tsxで Zod parse → props で受け取る。
// ヘルパー関数createMinimalProfile / createMinimalScorecardは `lib/data/factories.ts`。
// Pane 2 の表示用派生型 (CandidateRow / Group) と `SelectedDetail` 型は
// `lib/schema.ts` に集約(複数ペインで共有するため)。
// Pane 4 モード 2 用の `EditableScorecardKey` は
// `components/workspace/CandidateDetailPane.tsx` 内部の閉じた型。
// `updateScorecardField` の field 引数で使う key の union 型。Pane 4 内部の
// `EditableScorecardKey` と同形。CandidateDetailPane 内部に閉じた型として扱い
// たいため export せず、親側で同じ形を再宣言して持つ。
//
// ADR-0014「shadcn 標準フォームによる Pane 4 編集 UI」で `comment` / `summary`
// を `InlineTextareaField` で編集対象に追加したため、旧 4 フィールドから 6 フィールド
// に拡張。CandidateDetailPane.tsx 側の同型宣言line 70-76と同期させる。
// `onUpdateScorecardField` 実装本体は `[field]: value` のスプレッドで
// 動作するため、ロジックの追加修正は不要。
type EditableScorecardKey =
| "date"
| "format"
| "interviewer"
| "decision"
| "comment"
| "summary";
type WorkspaceProps = {
initialDepartments: Department[];
initialCandidates: Candidate[];
workspace: { name: string; icon: string };
};
export function Workspace({
initialDepartments,
initialCandidates,
workspace,
}: WorkspaceProps) {
const [departments, setDepartments] =
useState<Department[]>(initialDepartments);
const [candidates, setCandidates] = useState<Candidate[]>(initialCandidates);
const [selectedCandidateId, setSelectedCandidateId] = useState<string>("c2");
const [selectedDetail, setSelectedDetail] = useState<SelectedDetail>(null);
const [scrollAnchor, setScrollAnchor] = useState<string | null>(null);
// ユーザーが手動で Pane 4 を畳んだか。ステージ選択は保持しつつ畳む用途。
const [pane4ManuallyClosed, setPane4ManuallyClosed] = useState(false);
// Pane 3 ヘッダー帯Collapsibleの開閉。候補者切替で閉じ、新規追加で開く。
const [applicationInfoOpen, setApplicationInfoOpen] = useState(false);
// Pane 4 の展開状態を派生計算ADR-0015 §9 大決定 G
// selectedDetail !== null かつ手動で畳んでいない → 開いている。
const pane4Open = selectedDetail !== null && !pane4ManuallyClosed;
// アクティブ候補者を取得。`INITIAL_CANDIDATES` が常に最低 1 名持つ前提だが、
// 万一 find が undefined を返す(未来に candidates の削除機能が入った場合等)
// ケースに備えて先頭候補者にフォールバックする。
const activeCandidate =
candidates.find((c) => c.id === selectedCandidateId) ?? candidates[0];
const profile = activeCandidate.profile;
const scorecards = activeCandidate.scorecards;
// Mode1ProfileDetail は `setProfile: React.Dispatch<React.SetStateAction<Profile>>`
// を期待している(採用案 X。子コンポーネント側の signature を変えないために、
// candidates 配列を更新するアダプタをここで作る。
// 関数形 (p => next) と値形 (next) の両方に対応する。
const setProfile = useCallback<React.Dispatch<React.SetStateAction<Profile>>>(
(action) => {
setCandidates((prev) =>
prev.map((c) => {
if (c.id !== selectedCandidateId) return c;
const next =
typeof action === "function" ? action(c.profile) : action;
return { ...c, profile: next };
}),
);
},
[selectedCandidateId],
);
const openDetail = useCallback(
(next: SelectedDetail, anchor?: string) => {
if (next?.type === "stage") {
setCandidates((prev) =>
prev.map((c) => {
if (c.id !== selectedCandidateId) return c;
if (c.scorecards.some((s) => s.stage === next.stage)) return c;
return {
...c,
scorecards: [...c.scorecards, createMinimalScorecard(next.stage)],
};
}),
);
}
setSelectedDetail(next);
setScrollAnchor(anchor ?? null);
setPane4ManuallyClosed(false);
},
[selectedCandidateId],
);
// Pane 2 の候補者行クリックでアクティブ候補者を切り替える。
// - selectedDetail が「ステージ詳細」だった場合、新候補者にそのステージの
// scorecard が無ければ Pane 4 を **候補者詳細にフォールバック**する
// ADR-0011 §7 大決定 E、旧 null フォールバックを撤回。c2 以外は
// scorecards: [] のため、c2 → 別候補者の切替時はほぼ常に候詳へフォールバック。
// - selectedDetail が「候補者詳細」のときは維持してよいprofile はどの候補者にも
// 必ず存在する)。
// - previousDetail state は ADR-0011 §6 大決定 D で削除済みのため、リセット不要。
const selectCandidate = useCallback((id: string) => {
setSelectedCandidateId(id);
setSelectedDetail(null);
setApplicationInfoOpen(false);
setPane4ManuallyClosed(false);
}, []);
const addCandidate = useCallback((stage: StageKey, name: string) => {
const newId = `c-${Date.now()}`;
const newCandidate: Candidate = {
id: newId,
profile: createMinimalProfile(name),
scorecards: [],
stage,
};
setCandidates((prev) => [...prev, newCandidate]);
setSelectedCandidateId(newId);
setSelectedDetail(null);
setApplicationInfoOpen(true);
setPane4ManuallyClosed(false);
}, []);
const deleteCandidate = useCallback((id: string) => {
setCandidates((prev) => {
const next = prev.filter((c) => c.id !== id);
setSelectedCandidateId((prevId) => {
if (prevId !== id) return prevId;
return next.length > 0 ? next[0].id : "";
});
return next;
});
}, []);
const addDepartment = useCallback((name: string) => {
setDepartments((prev) => [
...prev,
{ id: `d-${Date.now()}`, name, positions: [] },
]);
}, []);
const deleteDepartment = useCallback((deptId: string) => {
setDepartments((prev) => prev.filter((d) => d.id !== deptId));
}, []);
const addPosition = useCallback((deptId: string, posName: string) => {
setDepartments((prev) =>
prev.map((d) =>
d.id === deptId
? {
...d,
positions: [
...d.positions,
{ id: `p-${Date.now()}`, name: posName, count: 0 },
],
}
: d,
),
);
}, []);
const deletePosition = useCallback((deptId: string, posId: string) => {
setDepartments((prev) =>
prev.map((d) =>
d.id === deptId
? { ...d, positions: d.positions.filter((p) => p.id !== posId) }
: d,
),
);
}, []);
// 評価観点 ★ の編集ハンドラ。フェーズ 3A から「アクティブ候補者」の
// scorecards を更新する形に変更candidates 配列の中の該当候補者だけを差し替え)。
const updateAxisScore = useCallback(
(stage: StageKey, axis: AxisKey, value: number | null) => {
setCandidates((prev) =>
prev.map((c) =>
c.id === selectedCandidateId
? {
...c,
scorecards: c.scorecards.map((s) =>
s.stage === stage
? { ...s, axisScores: { ...s.axisScores, [axis]: value } }
: s,
),
}
: c,
),
);
},
[selectedCandidateId],
);
// Pane 4 モード 2「メタ情報」の inline edit から呼ばれる。
// `decision` だけ undefined を許すため、空文字は undefined として扱う
// `MetaRow` 廃止前の "未判定" 表示の代替: `EditableFieldRow` 側で空 = "未設定")。
// フェーズ 3A: アクティブ候補者の scorecards を更新する形に変更。
const updateScorecardField = useCallback(
(stage: StageKey, field: EditableScorecardKey, value: string) => {
setCandidates((prev) =>
prev.map((c) =>
c.id === selectedCandidateId
? {
...c,
scorecards: c.scorecards.map((s) => {
if (s.stage !== stage) return s;
if (field === "decision") {
const trimmed = value.trim();
return {
...s,
decision: trimmed === "" ? undefined : trimmed,
};
}
return { ...s, [field]: value };
}),
}
: c,
),
);
},
[selectedCandidateId],
);
// Pane 4 内の `useEffect` 依存安定化のため、Workspace 側でメモ化して props で渡す。
const consumeScrollAnchor = useCallback(() => setScrollAnchor(null), []);
const togglePane4 = useCallback(() => setPane4ManuallyClosed((v) => !v), []);
const positionTitle = "フロントエンドエンジニア";
const departmentTitle = "プロダクト開発";
const candidateGroups: Group[] = useMemo(
() =>
STAGE_ORDER.map((stage) => ({
stage,
label: STAGE_LABELS[stage],
items: candidates
.filter((c) => c.stage === stage)
.map((c) => ({
id: c.id,
name: c.profile.name,
averageScore: getCandidateAverageScore(c),
})),
})).filter((g) => g.items.length > 0),
[candidates],
);
return (
// shadcn/ui の SidebarProvider が外側を取り、Pane 1 (`<Sidebar>`) を全高で固定
// 表示する。SidebarInset が右側ブロックGlobalHeader + Pane 2/3/4を担う。
// Cmd+B のキーバインドは SidebarProvider 側で標準実装されている。
// SidebarProvider のラッパー div は既定 `min-h-svh w-full`。雛形では
// ビューポート高に固定したいので h-screen を併記し、ペイン内で min-h-0 が
// 効くようにする(既存 ScrollArea の挙動と整合)。
<SidebarProvider
defaultOpen
className="h-screen w-full overflow-hidden bg-background text-foreground"
>
<PositionPane
workspaceName={workspace.name}
departments={departments}
selectedPositionName={positionTitle}
onAddPosition={addPosition}
onDeletePosition={deletePosition}
/>
<SidebarInset className="flex min-w-0 flex-col bg-background">
<GlobalHeader
departmentTitle={departmentTitle}
positionTitle={positionTitle}
candidateName={profile.name}
departments={departments}
onAddDepartment={addDepartment}
onDeleteDepartment={deleteDepartment}
/>
{/* SidebarInset 自体が <main> を出すので、内側は <div> で組み、
Pane 2 / Pane 3 / Pane 4 を横並びにする。 */}
<div className="flex min-h-0 flex-1">
<CandidateListPane
groups={candidateGroups}
selectedCandidateId={selectedCandidateId}
onSelectCandidate={selectCandidate}
onAddCandidate={addCandidate}
onDeleteCandidate={deleteCandidate}
/>
<CandidateDashboardPane
profile={profile}
scorecards={scorecards}
selectedDetail={selectedDetail}
onOpenDetail={openDetail}
setProfile={setProfile}
applicationInfoOpen={applicationInfoOpen}
onApplicationInfoOpenChange={setApplicationInfoOpen}
selectedCandidateId={selectedCandidateId}
/>
<CandidateDetailPane
selectedCandidateId={selectedCandidateId}
scorecards={scorecards}
selectedDetail={selectedDetail}
scrollAnchor={scrollAnchor}
onScrollAnchorConsumed={consumeScrollAnchor}
onUpdateAxis={updateAxisScore}
onUpdateScorecardField={updateScorecardField}
pane4Open={pane4Open}
onTogglePane4={togglePane4}
/>
</div>
</SidebarInset>
</SidebarProvider>
);
}