"use client";
/**
* Pane 3: 候補者ダッシュボード(ADR-0015 §19 で再設計)。
*
* 「人物軸の編集」として、ヘッダー帯(Collapsible)+ 採用条件カード + 選考フローカード
* の 3 要素で構成。判断の 3 問い(Q1: 採る価値 / Q2: 採れるか / Q3: 手遅れか)に
* スクロールなしで即答できることが目標。
*
* セクション順序: ヘッダー帯 → 採用条件 → 選考フロー
*
* `AxisScoreRow` は Pane 4 モード 2 評価セクションでも使うため引き続き export する。
*/
import { useState } from "react";
import {
ArrowUpRight,
Check,
Circle,
CircleDot,
ChevronDown,
Mail,
Phone,
MapPin,
X,
Star,
StarHalf,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
type Profile,
type StageStatus,
type Scorecard,
type SelectedDetail,
STAGE_ORDER,
} from "@/lib/schema";
import { STAGE_LABELS, PANE3_SECTION, PANE4_SECTION_IDS } from "@/lib/labels";
import {
getScorecardsAverageScore,
deriveStageStatus,
} from "@/lib/computed/scorecards";
import { calculateAge } from "@/lib/computed/profile";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
InlineTextField,
InlineDateField,
InlineComboboxField,
InlineTextareaField,
InlineFieldRow,
ScoreLabel,
SectionLabel,
type ComboOption,
} from "@/components/primitives";
// ===== AxisScoreRow(Pane 4 モード 2 でも使う、export) =====
export function AxisScoreRow({
label,
value,
bold = false,
editable = false,
onChange,
onReset,
}: {
label: string;
value: number | null;
bold?: boolean;
editable?: boolean;
onChange?: (value: number) => void;
onReset?: () => void;
}) {
if (value === null && !editable) {
return (
{label}
—
未評価
);
}
const v = value ?? 0;
const floor = Math.floor(v);
const hasHalf = !editable && v - floor >= 0.5;
return (
{label}
{[1, 2, 3, 4, 5].map((n) => {
const isFull = n <= floor;
const isHalf = hasHalf && n === floor + 1;
const StarComp = isHalf ? StarHalf : Star;
const filled = isFull || isHalf;
const starEl = (
);
if (editable && onChange) {
return (
);
}
return {starEl};
})}
{value !== null ? value.toFixed(1) : "—"}
{editable && onReset && value !== null && (
)}
);
}
// ===== ↗ ジャンプアイコン(ADR-0014 §4、常時表示) =====
function JumpIcon({
selected,
className,
}: {
selected: boolean;
className?: string;
}) {
return (
);
}
// ===== ステージアイコン(状態軸、ADR-0014 §7) =====
function StageIcon({ status }: { status: StageStatus }) {
if (status === "done") {
return (
);
}
if (status === "planned") {
return (
);
}
return (
);
}
// ===== Card: 採用条件(ADR-0015 §19.4 追加決定 M) =====
function RecruitingConditionsCard({
profile,
setProfile,
}: {
profile: Profile;
setProfile: React.Dispatch>;
}) {
const updateField = (key: K, value: Profile[K]) =>
setProfile((p) => ({ ...p, [key]: value }));
return (
{PANE3_SECTION.recruitingConditions}
updateField("desiredSalaryMin", v)}
ariaLabel="希望年収(下限)"
placeholder="—"
/>
〜
updateField("desiredSalaryMax", v)}
ariaLabel="希望年収(上限)"
placeholder="—"
/>
万円
updateField("availableStartDate", v)}
ariaLabel="入社可能日"
/>
);
}
// ===== Card: 選考フロー(ADR-0015 §19.5 担当者コメント統合版) =====
function ScreeningFlowListCard({
scorecards,
selectedDetail,
onOpenDetail,
}: {
scorecards: Scorecard[];
selectedDetail: SelectedDetail;
onOpenDetail: (next: SelectedDetail, scrollAnchor?: string) => void;
}) {
const allStages: Scorecard[] = STAGE_ORDER.map(
(stage) =>
scorecards.find((s) => s.stage === stage) ?? {
stage,
label: STAGE_LABELS[stage],
date: "",
format: "",
interviewer: "",
axisScores: {
achievements: null,
thinkingAbility: null,
communication: null,
cultureFit: null,
},
attachments: [],
},
);
return (
{PANE3_SECTION.screeningFlow}
{PANE3_SECTION.screeningFlowDescription}
{allStages.map((s, idx) => {
const selected =
selectedDetail?.type === "stage" &&
selectedDetail.stage === s.stage;
const status = deriveStageStatus(s.date, s.decision);
const showComment = status === "done" && !!s.comment;
return (
{idx > 0 &&
}
);
})}
);
}
// ===== 応募経路の選択肢(Pane 3 ヘッダー帯トグル内で使用) =====
const INITIAL_SOURCE_OPTIONS: ComboOption[] = [
{ value: "社員リファラル", description: "既存社員からの紹介で応募" },
{ value: "Wantedly", description: "Wantedly 経由の応募" },
{ value: "LinkedIn", description: "LinkedIn 経由の応募" },
{ value: "直接応募", description: "自社採用ページから直接応募" },
{ value: "エージェント経由", description: "採用エージェントを介した応募" },
];
// ===== ヘッダー帯トグル内の連絡先行 =====
function ContactRow({
icon,
label,
children,
}: {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
}) {
return (
{icon}
{label}
{children}
);
}
// ===== Card: 応募情報(折りたたみ可能) =====
function ApplicationInfoCardContent({
profile,
setProfile,
}: {
profile: Profile;
setProfile: React.Dispatch>;
}) {
const [sourceOptions, setSourceOptions] = useState(
INITIAL_SOURCE_OPTIONS,
);
const updateField = (key: K, value: Profile[K]) =>
setProfile((p) => ({ ...p, [key]: value }));
const handleAddSource = (newOpt: ComboOption) =>
setSourceOptions((prev) =>
prev.find((o) => o.value === newOpt.value) ? prev : [...prev, newOpt],
);
return (
{/* 基本(名前 / 生年月日 / 応募経路 / 採用担当) — Card タイトル「応募情報」が見出しを兼ねる */}
updateField("name", v)}
ariaLabel="名前"
/>
updateField("birthday", v)}
ariaLabel="生年月日"
/>
{calculateAge(profile.birthday)}
updateField("source", v)}
onCreate={handleAddSource}
ariaLabel="応募経路"
/>
updateField("recruiter", v)}
ariaLabel="採用担当"
/>
{/* 連絡先 */}
連絡先
}
label="メールアドレス"
>
updateField("email", v)}
ariaLabel="メールアドレス"
inputType="email"
/>
} label="電話番号">
updateField("phone", v)}
ariaLabel="電話番号"
inputType="tel"
/>
} label="住所">
updateField("address", v)}
ariaLabel="住所"
/>
{/* 職務経歴 */}
職務経歴
updateField("careerText", v)}
ariaLabel="職務経歴"
/>
{/* 志望動機 */}
志望動機
updateField("motivationFull", v)}
ariaLabel="志望動機"
/>
);
}
function ApplicationInfoCard({
profile,
setProfile,
open,
onOpenChange,
candidateKey,
}: {
profile: Profile;
setProfile: React.Dispatch>;
open: boolean;
onOpenChange: (open: boolean) => void;
candidateKey: string;
}) {
return (
}
>
{PANE3_SECTION.applicationInfo}
{`${PANE3_SECTION.applicationInfo}を開く`}
);
}
// ===== 候補者ヘッダー(固定。Avatar + 名前 + 年齢 / スコア) =====
function CandidateHeader({
profile,
scorecards,
}: {
profile: Profile;
scorecards: Scorecard[];
}) {
return (
{profile.name[0] ?? "?"}
{profile.name || "名前未設定"}
{profile.birthday && {calculateAge(profile.birthday)}}
{profile.birthday && ·}
);
}
// ===== Pane 3 メイン =====
export function CandidateDashboardPane({
profile,
scorecards,
selectedDetail,
onOpenDetail,
setProfile,
applicationInfoOpen,
onApplicationInfoOpenChange,
selectedCandidateId,
}: {
profile: Profile;
scorecards: Scorecard[];
selectedDetail: SelectedDetail;
onOpenDetail: (next: SelectedDetail, scrollAnchor?: string) => void;
setProfile: React.Dispatch>;
applicationInfoOpen: boolean;
onApplicationInfoOpenChange: (open: boolean) => void;
selectedCandidateId: string;
}) {
return (
);
}