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

330 lines
12 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";
/**
* 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<ComboOption[]>(
INITIAL_INTERVIEWER_OPTIONS,
);
const handleAddInterviewer = (newOpt: ComboOption) =>
setInterviewerOptions((prev) =>
prev.find((o) => o.value === newOpt.value) ? prev : [...prev, newOpt],
);
return (
<div>
{/* 基本情報(日時 / 形式 / 面接官 / 判定) */}
<Pane4Section id={PANE4_SECTION_IDS.m2.info} title="基本情報">
<dl className="flex flex-col gap-2.5 text-sm">
<InlineFieldRow label="日時">
<InlineDateField
value={scorecard.date}
onSave={(v) => onUpdateScorecardField(scorecard.stage, "date", v)}
ariaLabel="日時"
/>
</InlineFieldRow>
<InlineFieldRow label="形式">
<InlineSelectField
value={scorecard.format}
options={FORMAT_OPTIONS}
onSave={(v) =>
onUpdateScorecardField(scorecard.stage, "format", v)
}
ariaLabel="形式"
/>
</InlineFieldRow>
<InlineFieldRow label={interviewerLabel}>
<InlineComboboxField
value={scorecard.interviewer}
options={interviewerOptions}
onSave={(v) =>
onUpdateScorecardField(scorecard.stage, "interviewer", v)
}
onCreate={handleAddInterviewer}
ariaLabel={interviewerLabel}
/>
</InlineFieldRow>
<InlineFieldRow label="判定">
<InlineSelectField
value={scorecard.decision ?? ""}
options={DECISION_OPTIONS}
onSave={(v) =>
onUpdateScorecardField(scorecard.stage, "decision", v)
}
ariaLabel="判定"
/>
</InlineFieldRow>
</dl>
</Pane4Section>
<Separator />
{/* 評価4 観点 ★、編集可能。★ クリックで値設定 / × でリセット) */}
<Pane4Section id={PANE4_SECTION_IDS.m2.evaluation} title="評価">
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-3 py-3">
{AXIS_ORDER.map((key) => (
<AxisScoreRow
key={key}
label={EVALUATION_AXIS[key]}
value={scorecard.axisScores[key]}
editable
onChange={(v) => onUpdateAxis(scorecard.stage, key, v)}
onReset={() => onUpdateAxis(scorecard.stage, key, null)}
/>
))}
</div>
</Pane4Section>
<Separator />
{/* 担当者のコメントtextarea */}
<Pane4Section
id={PANE4_SECTION_IDS.m2.comment}
title="担当者のコメント"
className="gap-2"
>
<InlineTextareaField
value={scorecard.comment ?? ""}
onSave={(v) => onUpdateScorecardField(scorecard.stage, "comment", v)}
ariaLabel="担当者のコメント"
/>
</Pane4Section>
<Separator />
{/* 要約(書類選考: 書類の要約 / 面接: 面接の要約) */}
<Pane4Section
id={PANE4_SECTION_IDS.m2.summary}
title={summaryHeading}
className="gap-2"
>
<InlineTextareaField
value={scorecard.summary ?? ""}
onSave={(v) => onUpdateScorecardField(scorecard.stage, "summary", v)}
ariaLabel={summaryHeading}
/>
</Pane4Section>
<Separator />
{/* 添付(書類選考: 提出書類 / 面接: 添付)— `AttachmentList` を再利用 */}
<Pane4Section
id={PANE4_SECTION_IDS.m2.attachments}
title={attachmentHeading}
>
<AttachmentList items={scorecard.attachments} />
</Pane4Section>
</div>
);
}
// ===== Pane 4 メイン =====
/**
* Pane 4: 選考ステージ詳細パネルADR-0015 §19 で候補者詳細を Pane 3 に移管)。
*
* - ヘッダー: ステージ名 + Pane4Toggle の 2 要素(◀ は撤廃)
* - ステージ切替: `<Mode2StageDetail key={...}>` で再マウント
* - ステージ未選択時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 (
<aside
className={cn(
"flex shrink-0 flex-col border-l border-border bg-background",
"overflow-hidden transition-[width] duration-200 ease-linear",
pane4Open ? "w-[400px]" : "w-12",
)}
>
{pane4Open ? (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
<h2 className="flex-1 truncate text-sm font-semibold text-foreground">
{heading}
</h2>
<Pane4Toggle open={pane4Open} onToggle={onTogglePane4} />
</header>
<ScrollArea className="min-h-0 flex-1">
{selectedDetail?.type === "stage" && (
<Mode2StageDetail
key={`${selectedCandidateId}-${selectedDetail.stage}`}
scorecard={
scorecards.find((s) => s.stage === selectedDetail.stage) ??
createMinimalScorecard(selectedDetail.stage)
}
onUpdateAxis={onUpdateAxis}
onUpdateScorecardField={onUpdateScorecardField}
/>
)}
</ScrollArea>
</>
) : (
<div className="flex h-12 shrink-0 items-center justify-center border-b border-border">
<Pane4Toggle open={pane4Open} onToggle={onTogglePane4} />
</div>
)}
</aside>
);
}