"use client"; /** * Pane 4: 選考ステージ詳細パネル(ADR-0015 §19 でモード 1 を廃止)。 * * ステージ詳細のみ表示する。候補者詳細(旧モード 1)は Pane 3 のヘッダー帯 * トグル内に移管済み。起動時は畳まれた 48px 帯で、Pane 3 のステージカード * クリックで自動展開する。 * * 規律: * - components/primitives/ の Inline* primitive を使う(shadcn 標準フォーム) * - AttachmentList を再利用(添付セクション) * - AxisScoreRow を CandidateDashboardPane から import * - ステージ切替時の state リセットは `key` 再マウントで * * `SelectedDetail` 型は `lib/schema.ts` に集約(Phase 3A)。 */ import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { Pane4Toggle } from "@/components/workspace/Pane4Toggle"; import { type AxisKey, type StageKey, type Scorecard, type SelectedDetail, AXIS_ORDER, } from "@/lib/schema"; import { EVALUATION_AXIS, PANE4_SECTION_IDS } from "@/lib/labels"; import { createMinimalScorecard } from "@/lib/data/factories"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { InlineDateField, InlineSelectField, InlineComboboxField, InlineTextareaField, InlineFieldRow, type ComboOption, } from "@/components/primitives"; import { Pane4Section } from "@/components/workspace/Pane4Section"; import { AxisScoreRow } from "@/components/workspace/CandidateDashboardPane"; import { AttachmentList } from "@/components/workspace/AttachmentList"; // ===== Pane 4 内部型(ファイル外には出さない) ===== /** * Pane 4 モード 2 で inline 編集できる Scorecard のキー集合。 * `onUpdateScorecardField` の `field` 引数の型として親 (Workspace.tsx) と整合させる。 * * 旧実装は `date / format / interviewer / decision` の 4 フィールドのみだったが、 * ADR-0014 でコメント / 要約も `InlineTextareaField` で編集対象にしたため * `comment` / `summary` を追加した。Workspace.tsx 側の `EditableScorecardKey` * 再宣言も同形に揃える必要がある(型を export しない設計のため両側で宣言する規律)。 */ type EditableScorecardKey = | "date" | "format" | "interviewer" | "decision" | "comment" | "summary"; // ===== 定数(screening-lab / profile-card-lab から移植) ===== const FORMAT_OPTIONS = [ "書類", "オンライン (Google Meet)", "オフライン", ] as const; const DECISION_OPTIONS = ["通過", "保留", "不合格"] as const; const INITIAL_INTERVIEWER_OPTIONS: ComboOption[] = [ { value: "田中 花子", description: "採用担当チーム" }, { value: "佐藤 太郎", description: "エンジニアリングマネージャー" }, { value: "鈴木 一郎", description: "シニアエンジニア" }, { value: "山田 美咲", description: "プロダクトマネージャー" }, ]; // ===== 選考ステージ詳細(旧モード 2、ADR-0015 で唯一のモードに) ===== /** * モード 2(選考ステージ詳細、書類選考 / 面接 共通テンプレート)。 * 基本情報 / 評価 / コメント / 要約 / 添付 の 5 ブロック構成(screening-lab と一致)。 * * ステージ別ラベル分岐(ADR-0010 §9 G): * - 「面接官」フィールド: 書類選考のみ「審査担当」、それ以外は「面接官」 * - 「要約」見出し: 書類選考のみ「書類の要約」、それ以外は「面接の要約」 * - 「添付」見出し: 書類選考のみ「提出書類」、それ以外は「添付」 * * 各フィールドは `components/primitives/` の Inline* primitive で常時表示される * (Type-direct)。`interviewerOptions` は候補者+ステージ単位でメモリ保持し、 * 親側 `key` でステージ・候補者切替時に自然リセット(Effect 内同期 setState 禁止)。 */ function Mode2StageDetail({ scorecard, onUpdateAxis, onUpdateScorecardField, }: { scorecard: Scorecard; onUpdateAxis: (stage: StageKey, axis: AxisKey, value: number | null) => void; onUpdateScorecardField: ( stage: StageKey, field: EditableScorecardKey, value: string, ) => void; }) { const isScreening = scorecard.stage === "screening"; const interviewerLabel = isScreening ? "審査担当" : "面接官"; const summaryHeading = isScreening ? "書類の要約" : "面接の要約"; const attachmentHeading = isScreening ? "提出書類" : "添付"; const [interviewerOptions, setInterviewerOptions] = useState( INITIAL_INTERVIEWER_OPTIONS, ); const handleAddInterviewer = (newOpt: ComboOption) => setInterviewerOptions((prev) => prev.find((o) => o.value === newOpt.value) ? prev : [...prev, newOpt], ); return (
{/* 基本情報(日時 / 形式 / 面接官 / 判定) */}
onUpdateScorecardField(scorecard.stage, "date", v)} ariaLabel="日時" /> onUpdateScorecardField(scorecard.stage, "format", v) } ariaLabel="形式" /> onUpdateScorecardField(scorecard.stage, "interviewer", v) } onCreate={handleAddInterviewer} ariaLabel={interviewerLabel} /> onUpdateScorecardField(scorecard.stage, "decision", v) } ariaLabel="判定" />
{/* 評価(4 観点 ★、編集可能。★ クリックで値設定 / × でリセット) */}
{AXIS_ORDER.map((key) => ( onUpdateAxis(scorecard.stage, key, v)} onReset={() => onUpdateAxis(scorecard.stage, key, null)} /> ))}
{/* 担当者のコメント(textarea) */} onUpdateScorecardField(scorecard.stage, "comment", v)} ariaLabel="担当者のコメント" /> {/* 要約(書類選考: 書類の要約 / 面接: 面接の要約) */} onUpdateScorecardField(scorecard.stage, "summary", v)} ariaLabel={summaryHeading} /> {/* 添付(書類選考: 提出書類 / 面接: 添付)— `AttachmentList` を再利用 */}
); } // ===== Pane 4 メイン ===== /** * Pane 4: 選考ステージ詳細パネル(ADR-0015 §19 で候補者詳細を Pane 3 に移管)。 * * - ヘッダー: ステージ名 + Pane4Toggle の 2 要素(◀ は撤廃) * - ステージ切替: `` で再マウント * - ステージ未選択時(selectedDetail === null 想定、Phase 3C 暫定で type=profile も * ステージなしとして扱う): コンテンツなし、ヘッダーのみ */ export function CandidateDetailPane({ selectedCandidateId, scorecards, selectedDetail, scrollAnchor, onScrollAnchorConsumed, onUpdateAxis, onUpdateScorecardField, pane4Open, onTogglePane4, }: { selectedCandidateId: string; scorecards: Scorecard[]; selectedDetail: SelectedDetail; scrollAnchor: string | null; onScrollAnchorConsumed: () => void; onUpdateAxis: (stage: StageKey, axis: AxisKey, value: number | null) => void; onUpdateScorecardField: ( stage: StageKey, field: EditableScorecardKey, value: string, ) => void; pane4Open: boolean; onTogglePane4: () => void; }) { useEffect(() => { if (!scrollAnchor) return; // 1 フレーム待つのは、Pane 4 が閉状態 → 開状態へ切り替わった直後に // 対象セクションの DOM がレイアウトされるのを待つため。 // unmount された場合や anchor が変わった場合は cancel して、後続の // `scrollIntoView` と `onScrollAnchorConsumed`(state setter)が走らないようにする。 const id = requestAnimationFrame(() => { document .getElementById(scrollAnchor) ?.scrollIntoView({ block: "start", behavior: "smooth" }); onScrollAnchorConsumed(); }); return () => cancelAnimationFrame(id); }, [scrollAnchor, onScrollAnchorConsumed]); const heading = selectedDetail?.type === "stage" ? (scorecards.find((s) => s.stage === selectedDetail.stage)?.label ?? "詳細") : "ステージ詳細"; return ( ); }