"use client"; /** * Workspace: 4 ペインの親コンポーネント。 * * - Pane 1〜4 の state(candidates / 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 を本実装で改訂): * * ``` * (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 * §2(4 段階背景色階層)/ §4(保存ステータス削除)はそのまま採用 * §3(Pane 4 = 画面最上端〜最下端 / ヘッダーは中央エリアのみ)は本実装で再改訂 * §5(SidebarTrigger は GlobalHeader)も本実装で再改訂(Pane 1 ヘッダー側に集約) * - openspec/decision/0009-drilldown-card-affordance.md(Pane 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 { ARCHIVED_GROUP_LABEL, 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 Component(app/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(initialDepartments); const [candidates, setCandidates] = useState(initialCandidates); const [selectedCandidateId, setSelectedCandidateId] = useState("c2"); const [selectedDetail, setSelectedDetail] = useState(null); const [scrollAnchor, setScrollAnchor] = useState(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>` // を期待している(採用案 X)。子コンポーネント側の signature を変えないために、 // candidates 配列を更新するアダプタをここで作る。 // 関数形 (p => next) と値形 (next) の両方に対応する。 const setProfile = useCallback>>( (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, archived: false, }; setCandidates((prev) => [...prev, newCandidate]); setSelectedCandidateId(newId); setSelectedDetail(null); setApplicationInfoOpen(true); setPane4ManuallyClosed(false); }, []); // 候補者をアーカイブ(論理削除)する。データは残し `archived: true` を立てる。 // 復元は `restoreCandidate` から、もしくは Pane 2「アーカイブ済み」グループの // 「復元」ボタン経由。アクティブ候補者をアーカイブした場合は、非 archived の // 先頭候補者にフォールバックし、ステージ詳細(Pane 4)はクリアする。 const archiveCandidate = useCallback((id: string) => { setCandidates((prev) => { const next = prev.map((c) => c.id === id ? { ...c, archived: true } : c, ); setSelectedCandidateId((prevId) => { if (prevId !== id) return prevId; const fallback = next.find((c) => !c.archived); return fallback ? fallback.id : ""; }); return next; }); setSelectedDetail(null); setPane4ManuallyClosed(false); }, []); // アーカイブ済み候補者を元のステージに復元する。`stage` は archived 中も保持 // しているので、そのステージへ戻すだけでよい。 const restoreCandidate = useCallback((id: string) => { setCandidates((prev) => prev.map((c) => (c.id === id ? { ...c, archived: false } : c)), ); }, []); // 候補者を別ステージへ移動 / 同ステージ内で並び替え。 // // `toStage` は移動先のステージキー。`toIndex` はそのステージグループ内での // 0-origin の挿入位置。配列順を SSoT としているため、candidates 配列上の // 絶対インデックスに変換して `splice` 相当の挿入を行う。 // // 同ステージ内ドラッグ・別ステージへのドラッグの両方をこの 1 関数で扱う。 // archived 候補者は対象外(DnD はアクティブな候補者のみ可能)。 const moveCandidate = useCallback( (id: string, toStage: StageKey, toIndex: number) => { setCandidates((prev) => { const subjectIndex = prev.findIndex((c) => c.id === id); if (subjectIndex < 0) return prev; const subject = prev[subjectIndex]; if (subject.archived) return prev; const without = prev.filter((_, i) => i !== subjectIndex); const updated: Candidate = { ...subject, stage: toStage }; let count = 0; let absInsertAt = without.length; for (let i = 0; i < without.length; i++) { const c = without[i]; if (!c.archived && c.stage === toStage) { if (count === toIndex) { absInsertAt = i; break; } count++; } } return [ ...without.slice(0, absInsertAt), updated, ...without.slice(absInsertAt), ]; }); }, [], ); 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(() => { // ステージグループは常に 4 段階すべて表示する。空ステージも残すことで、 // 「最後の 1 名を別ステージへ動かしたら戻し先が消える」事故を防ぐ // (ADR-006 §2-2 の補足)。 const stageGroups: Group[] = STAGE_ORDER.map((stage) => ({ kind: "stage" as const, stage, label: STAGE_LABELS[stage], items: candidates .filter((c) => !c.archived && c.stage === stage) .map((c) => ({ id: c.id, name: c.profile.name, averageScore: getCandidateAverageScore(c), })), })); const archivedItems = candidates .filter((c) => c.archived) .map((c) => ({ id: c.id, name: c.profile.name, averageScore: getCandidateAverageScore(c), })); if (archivedItems.length === 0) return stageGroups; return [ ...stageGroups, { kind: "archived" as const, label: ARCHIVED_GROUP_LABEL, items: archivedItems }, ]; }, [candidates]); return ( // shadcn/ui の SidebarProvider が外側を取り、Pane 1 (``) を全高で固定 // 表示する。SidebarInset が右側ブロック(GlobalHeader + Pane 2/3/4)を担う。 // Cmd+B のキーバインドは SidebarProvider 側で標準実装されている。 // SidebarProvider のラッパー div は既定 `min-h-svh w-full`。雛形では // ビューポート高に固定したいので h-screen を併記し、ペイン内で min-h-0 が // 効くようにする(既存 ScrollArea の挙動と整合)。 {/* SidebarInset 自体が
を出すので、内側は
で組み、 Pane 2 / Pane 3 / Pane 4 を横並びにする。 */}
); }