@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FieldGroup, Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type AddItemDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
fieldLabel: string;
|
||||
fieldId: string;
|
||||
placeholder: string;
|
||||
onAdd: (name: string) => void;
|
||||
};
|
||||
|
||||
export function AddItemDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fieldLabel,
|
||||
fieldId,
|
||||
placeholder,
|
||||
onAdd,
|
||||
}: AddItemDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onAdd(trimmed);
|
||||
setName("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
if (!v) setName("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId}>{fieldLabel}</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline">キャンセル</Button>} />
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
追加
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 添付ファイル一覧(Pane 4 モード 2 の「提出書類 / 文字起こし」共通リスト)。
|
||||
*
|
||||
* ADR-0010 §13 D75 で「添付セクションは Attachment 統一型のファイル一覧」に統合。
|
||||
* 旧「提出書類は PDF リスト / 文字起こしはテキスト塊」の二分岐を廃止し、
|
||||
* 両ステージで同じ AttachmentList を使う。
|
||||
*
|
||||
* 行のマークアップ規律(レビュー反映):
|
||||
* - `<li>` 自体には role / onClick を持たせない(ネスト button を回避、WAI-ARIA APG 準拠)
|
||||
* - `<li>` 内に **兄弟関係の native `<button>` 2 つ** を配置:
|
||||
* 1. プレビュー用ワイドボタン(kind: "txt" のときのみ有効、kind: "pdf" は disabled)
|
||||
* 2. ダウンロード用アイコンボタン(常に有効、stopPropagation 不要)
|
||||
* - `focus-within:ring-2` で行全体の focus 可視化
|
||||
* - PDF 行は `disabled` + 「プレビュー不可」バッジで「クリックしても開かない」を明示
|
||||
*
|
||||
* 状態管理規律(ADR-0010 §6/§7/§8 準拠):
|
||||
* - `openItem` state は本コンポーネント内 useState で持つ
|
||||
* - 候補者・ステージ切替は親 (CandidateDetailPane) 側の
|
||||
* `key={selectedCandidateId}-${stage}` でツリー再マウントされ、モーダルも自然に閉じる
|
||||
* - useEffect 内で setOpenItem(null) するパターンは MUST 禁止
|
||||
*
|
||||
* ダミー DL は `console.info("[stub] download:", file.id)` の observable stub
|
||||
* (noop 禁止、雛形教材として「動いている」ことを受講生に示す)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
|
||||
import { type Attachment } from "@/lib/schema";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AttachmentPreviewDialog } from "@/components/workspace/AttachmentPreviewDialog";
|
||||
|
||||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||||
const [openItem, setOpenItem] = useState<Attachment | null>(null);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="px-1 py-2 text-sm text-muted-foreground">
|
||||
添付はまだありません。ヘッダーの「アップロード」から追加できます。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((file) => {
|
||||
const isTxt = file.kind === "txt";
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-stretch overflow-hidden rounded-lg border border-border bg-card transition"
|
||||
>
|
||||
{/* 兄弟 button 1: プレビュー(PDF は disabled) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isTxt) {
|
||||
setOpenItem(file);
|
||||
}
|
||||
}}
|
||||
disabled={!isTxt}
|
||||
aria-label={
|
||||
isTxt
|
||||
? `${file.name} のプレビューを開く`
|
||||
: `${file.name}(プレビュー不可)`
|
||||
}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-3 py-2.5 text-left transition outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:bg-transparent"
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{file.name}
|
||||
</span>
|
||||
{!isTxt && (
|
||||
<span className="ml-1 shrink-0 rounded border border-border px-1 text-[10px] text-muted-foreground">
|
||||
プレビュー不可
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{file.size}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Separator orientation="vertical" aria-hidden />
|
||||
|
||||
{/* 兄弟 button 2: ダウンロード(独立コントロール、stopPropagation 不要) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => console.info("[stub] download:", file.id)}
|
||||
aria-label={`${file.name} をダウンロード`}
|
||||
className="flex shrink-0 items-center px-2.5 transition outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<Download className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<AttachmentPreviewDialog
|
||||
item={openItem}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setOpenItem(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 添付ファイル中身プレビューモーダル(Pane 4 モード 2 / AttachmentList から開く)。
|
||||
*
|
||||
* - shadcn Dialog 公式構造(Title / Description / Header / ScrollArea)に揃える
|
||||
* - WAI-ARIA Modal Dialog Pattern: aria-labelledby(Title 自動接続)+ aria-describedby
|
||||
* (Description 自動接続)+ Esc 閉じ + focus 戻し(Base UI Dialog で標準提供)
|
||||
* - txt は本文を whitespace-pre-wrap で表示。font-mono は使わず、日本語長文の
|
||||
* 可読性を優先(ADR-0010 §13 D75 / レビュー反映)
|
||||
* - PDF はそもそも `AttachmentList` 側で `disabled` のためモーダルが開かない。
|
||||
* この Dialog では PDF 分岐を持たない(YAGNI、PDF プレビューは雛形外)
|
||||
*
|
||||
* ADR 出典: ADR-0010 §13 / design.md D75
|
||||
*/
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { type Attachment } from "@/lib/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function AttachmentPreviewDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: Attachment | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
// モーダルは「txt の Attachment」専用。PDF は親側で disabled されているため
|
||||
// ここに到達しない前提だが、型安全のため item?.kind === "txt" でガードする。
|
||||
return (
|
||||
<Dialog open={item !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col gap-3">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-2 pr-8">
|
||||
<DialogTitle className="truncate">{item?.name ?? ""}</DialogTitle>
|
||||
{item && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
aria-label={`${item.name} をダウンロード`}
|
||||
onClick={() => console.info("[stub] download:", item.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
ダウンロード
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Esc キーまたは右上 × で閉じます。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="-mx-1 min-h-0 flex-1 px-1">
|
||||
{item?.kind === "txt" ? (
|
||||
<div className="py-1 text-sm leading-relaxed whitespace-pre-wrap text-foreground">
|
||||
{item.previewText}
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
"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 (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
bold && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="tabular-nums">—</span>
|
||||
<span className="text-[11px]">未評価</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const v = value ?? 0;
|
||||
const floor = Math.floor(v);
|
||||
const hasHalf = !editable && v - floor >= 0.5;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
bold && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5">
|
||||
{[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 = (
|
||||
<StarComp
|
||||
className={cn(
|
||||
"size-3",
|
||||
filled
|
||||
? "fill-primary text-primary"
|
||||
: "fill-muted-foreground/20 text-muted-foreground/30",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
if (editable && onChange) {
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
className="rounded-sm transition-opacity outline-none hover:opacity-70 focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
aria-label={`${label} を ${n} に設定`}
|
||||
>
|
||||
{starEl}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span key={n}>{starEl}</span>;
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[2.25rem] text-right text-foreground tabular-nums",
|
||||
bold && "font-medium",
|
||||
value === null && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value !== null ? value.toFixed(1) : "—"}
|
||||
</span>
|
||||
{editable && onReset && value !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="ml-1 inline-flex size-5 items-center justify-center rounded text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
aria-label={`${label} の評価をリセット`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== ↗ ジャンプアイコン(ADR-0014 §4、常時表示) =====
|
||||
|
||||
function JumpIcon({
|
||||
selected,
|
||||
className,
|
||||
}: {
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ArrowUpRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
selected ? "text-primary" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== ステージアイコン(状態軸、ADR-0014 §7) =====
|
||||
|
||||
function StageIcon({ status }: { status: StageStatus }) {
|
||||
if (status === "done") {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||
aria-label="完了"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "planned") {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground"
|
||||
aria-label="予定"
|
||||
>
|
||||
<CircleDot className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground"
|
||||
aria-label="未着手"
|
||||
>
|
||||
<Circle className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Card: 採用条件(ADR-0015 §19.4 追加決定 M) =====
|
||||
|
||||
function RecruitingConditionsCard({
|
||||
profile,
|
||||
setProfile,
|
||||
}: {
|
||||
profile: Profile;
|
||||
setProfile: React.Dispatch<React.SetStateAction<Profile>>;
|
||||
}) {
|
||||
const updateField = <K extends keyof Profile>(key: K, value: Profile[K]) =>
|
||||
setProfile((p) => ({ ...p, [key]: value }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle emphasis="prominent">
|
||||
{PANE3_SECTION.recruitingConditions}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-2.5 text-sm">
|
||||
<InlineFieldRow label="希望年収">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-20">
|
||||
<InlineTextField
|
||||
value={profile.desiredSalaryMin}
|
||||
onSave={(v) => updateField("desiredSalaryMin", v)}
|
||||
ariaLabel="希望年収(下限)"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">〜</span>
|
||||
<div className="w-20">
|
||||
<InlineTextField
|
||||
value={profile.desiredSalaryMax}
|
||||
onSave={(v) => updateField("desiredSalaryMax", v)}
|
||||
ariaLabel="希望年収(上限)"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
万円
|
||||
</span>
|
||||
</div>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow label="入社可能日">
|
||||
<InlineDateField
|
||||
value={profile.availableStartDate}
|
||||
onSave={(v) => updateField("availableStartDate", v)}
|
||||
ariaLabel="入社可能日"
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle emphasis="prominent">
|
||||
{PANE3_SECTION.screeningFlow}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{PANE3_SECTION.screeningFlowDescription}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
{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 (
|
||||
<div key={s.stage}>
|
||||
{idx > 0 && <Separator className="my-1" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenDetail(
|
||||
{ type: "stage", stage: s.stage },
|
||||
PANE4_SECTION_IDS.m2.info,
|
||||
)
|
||||
}
|
||||
aria-label={`Pane 4 で ${s.label} を開く`}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-md px-2 py-2 text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<StageIcon status={status} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{s.label}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{s.date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.date}
|
||||
</span>
|
||||
)}
|
||||
{s.decision && (
|
||||
<Badge variant="outline" size="xs">
|
||||
{s.decision}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.interviewer || "未予定"}
|
||||
</p>
|
||||
{showComment && (
|
||||
<p className="mt-0.5 line-clamp-2 border-l-2 border-primary/20 pl-2 text-xs leading-relaxed text-foreground/80">
|
||||
"{s.comment}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<JumpIcon selected={selected} className="mt-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 応募経路の選択肢(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 (
|
||||
<li className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Card: 応募情報(折りたたみ可能) =====
|
||||
|
||||
function ApplicationInfoCardContent({
|
||||
profile,
|
||||
setProfile,
|
||||
}: {
|
||||
profile: Profile;
|
||||
setProfile: React.Dispatch<React.SetStateAction<Profile>>;
|
||||
}) {
|
||||
const [sourceOptions, setSourceOptions] = useState<ComboOption[]>(
|
||||
INITIAL_SOURCE_OPTIONS,
|
||||
);
|
||||
|
||||
const updateField = <K extends keyof Profile>(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 (
|
||||
<div className="flex flex-col">
|
||||
{/* 基本(名前 / 生年月日 / 応募経路 / 採用担当) — Card タイトル「応募情報」が見出しを兼ねる */}
|
||||
<section className="flex flex-col gap-3 pb-4">
|
||||
<dl className="flex flex-col gap-2.5 text-sm">
|
||||
<InlineFieldRow label="名前">
|
||||
<InlineTextField
|
||||
value={profile.name}
|
||||
onSave={(v) => updateField("name", v)}
|
||||
ariaLabel="名前"
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow label="生年月日">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<InlineDateField
|
||||
value={profile.birthday}
|
||||
onSave={(v) => updateField("birthday", v)}
|
||||
ariaLabel="生年月日"
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{calculateAge(profile.birthday)}
|
||||
</span>
|
||||
</div>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow label="応募経路">
|
||||
<InlineComboboxField
|
||||
value={profile.source}
|
||||
options={sourceOptions}
|
||||
onSave={(v) => updateField("source", v)}
|
||||
onCreate={handleAddSource}
|
||||
ariaLabel="応募経路"
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow label="採用担当">
|
||||
<InlineTextField
|
||||
value={profile.recruiter}
|
||||
onSave={(v) => updateField("recruiter", v)}
|
||||
ariaLabel="採用担当"
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 連絡先 */}
|
||||
<section className="flex flex-col gap-3 py-4">
|
||||
<SectionLabel>連絡先</SectionLabel>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<ContactRow
|
||||
icon={<Mail className="size-3.5" />}
|
||||
label="メールアドレス"
|
||||
>
|
||||
<InlineTextField
|
||||
value={profile.email}
|
||||
onSave={(v) => updateField("email", v)}
|
||||
ariaLabel="メールアドレス"
|
||||
inputType="email"
|
||||
/>
|
||||
</ContactRow>
|
||||
<ContactRow icon={<Phone className="size-3.5" />} label="電話番号">
|
||||
<InlineTextField
|
||||
value={profile.phone}
|
||||
onSave={(v) => updateField("phone", v)}
|
||||
ariaLabel="電話番号"
|
||||
inputType="tel"
|
||||
/>
|
||||
</ContactRow>
|
||||
<ContactRow icon={<MapPin className="size-3.5" />} label="住所">
|
||||
<InlineTextField
|
||||
value={profile.address}
|
||||
onSave={(v) => updateField("address", v)}
|
||||
ariaLabel="住所"
|
||||
/>
|
||||
</ContactRow>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 職務経歴 */}
|
||||
<section className="flex flex-col gap-2 py-4">
|
||||
<SectionLabel>職務経歴</SectionLabel>
|
||||
<InlineTextareaField
|
||||
value={profile.careerText}
|
||||
onSave={(v) => updateField("careerText", v)}
|
||||
ariaLabel="職務経歴"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 志望動機 */}
|
||||
<section className="flex flex-col gap-2 pt-4">
|
||||
<SectionLabel>志望動機</SectionLabel>
|
||||
<InlineTextareaField
|
||||
value={profile.motivationFull}
|
||||
onSave={(v) => updateField("motivationFull", v)}
|
||||
ariaLabel="志望動機"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplicationInfoCard({
|
||||
profile,
|
||||
setProfile,
|
||||
open,
|
||||
onOpenChange,
|
||||
candidateKey,
|
||||
}: {
|
||||
profile: Profile;
|
||||
setProfile: React.Dispatch<React.SetStateAction<Profile>>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
candidateKey: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<CardHeader className="group/trigger cursor-pointer rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
|
||||
}
|
||||
>
|
||||
<CardTitle emphasis="prominent">
|
||||
{PANE3_SECTION.applicationInfo}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="size-4 text-muted-foreground transition-[color,transform] group-hover/trigger:text-foreground in-data-[panel-open]:rotate-180"
|
||||
/>
|
||||
<span className="sr-only">{`${PANE3_SECTION.applicationInfo}を開く`}</span>
|
||||
</CardAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
<ApplicationInfoCardContent
|
||||
key={candidateKey}
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
/>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 候補者ヘッダー(固定。Avatar + 名前 + 年齢 / スコア) =====
|
||||
|
||||
function CandidateHeader({
|
||||
profile,
|
||||
scorecards,
|
||||
}: {
|
||||
profile: Profile;
|
||||
scorecards: Scorecard[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<Avatar className="size-16">
|
||||
<AvatarFallback className="bg-primary/10 text-xl text-primary">
|
||||
{profile.name[0] ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h2 className="font-heading truncate text-xl font-semibold text-foreground">
|
||||
{profile.name || "名前未設定"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{profile.birthday && <span>{calculateAge(profile.birthday)}</span>}
|
||||
{profile.birthday && <span aria-hidden="true">·</span>}
|
||||
<ScoreLabel value={getScorecardsAverageScore(scorecards)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 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<React.SetStateAction<Profile>>;
|
||||
applicationInfoOpen: boolean;
|
||||
onApplicationInfoOpenChange: (open: boolean) => void;
|
||||
selectedCandidateId: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="min-w-0 flex-1 bg-canvas">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 px-8 py-8">
|
||||
<CandidateHeader profile={profile} scorecards={scorecards} />
|
||||
|
||||
<ApplicationInfoCard
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
open={applicationInfoOpen}
|
||||
onOpenChange={onApplicationInfoOpenChange}
|
||||
candidateKey={selectedCandidateId}
|
||||
/>
|
||||
|
||||
<RecruitingConditionsCard profile={profile} setProfile={setProfile} />
|
||||
|
||||
<ScreeningFlowListCard
|
||||
scorecards={scorecards}
|
||||
selectedDetail={selectedDetail}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Plus, Star, Trash2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type Group, type StageKey } from "@/lib/schema";
|
||||
import { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
|
||||
|
||||
type CandidateListPaneProps = {
|
||||
groups: Group[];
|
||||
selectedCandidateId: string;
|
||||
onSelectCandidate: (id: string) => void;
|
||||
onAddCandidate: (stage: StageKey, name: string) => void;
|
||||
onDeleteCandidate: (id: string) => void;
|
||||
};
|
||||
|
||||
export function CandidateListPane({
|
||||
groups,
|
||||
selectedCandidateId,
|
||||
onSelectCandidate,
|
||||
onAddCandidate,
|
||||
onDeleteCandidate,
|
||||
}: CandidateListPaneProps) {
|
||||
const [addDialogStage, setAddDialogStage] = useState<{
|
||||
stage: StageKey;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
return (
|
||||
<section className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background">
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-3">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
フロントエンドエンジニア
|
||||
</h2>
|
||||
</header>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-5 px-3 py-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.stage}>
|
||||
<div className="sticky top-0 z-10 -mx-3 mb-2 flex items-center justify-between gap-2 bg-background px-5 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h3 className="truncate text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
<Badge variant="secondary" size="xs">
|
||||
{group.items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setAddDialogStage({
|
||||
stage: group.stage,
|
||||
label: group.label,
|
||||
})
|
||||
}
|
||||
aria-label={`${group.label} に候補者を追加`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{group.items.map((cand) => {
|
||||
const selected = cand.id === selectedCandidateId;
|
||||
return (
|
||||
<li key={cand.id} className="group/candidate relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectCandidate(cand.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-2.5 py-2.5 text-left transition-colors",
|
||||
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarFallback className="bg-primary/10 text-xs text-primary">
|
||||
{cand.name[0] ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{cand.name}</p>
|
||||
</div>
|
||||
<span className="transition-opacity group-focus-within/candidate:opacity-0 group-hover/candidate:opacity-0">
|
||||
<ScoreBadge
|
||||
avg={cand.averageScore}
|
||||
selected={selected}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1 -translate-y-1/2",
|
||||
"opacity-0 group-focus-within/candidate:opacity-100 group-hover/candidate:opacity-100",
|
||||
"transition-opacity",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={`${cand.name} の操作`}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() =>
|
||||
setDeleteTarget({
|
||||
id: cand.id,
|
||||
name: cand.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
削除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{addDialogStage && (
|
||||
<AddItemDialog
|
||||
open={addDialogStage !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAddDialogStage(null);
|
||||
}}
|
||||
title="候補者を追加"
|
||||
description={`「${addDialogStage.label}」ステージに候補者を追加します`}
|
||||
fieldLabel="氏名"
|
||||
fieldId="candidate-name"
|
||||
placeholder="例: 山田 太郎"
|
||||
onAdd={(name) => onAddCandidate(addDialogStage.stage, name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
title="候補者を削除しますか?"
|
||||
itemName={deleteTarget?.name ?? ""}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
onDeleteCandidate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge({
|
||||
avg,
|
||||
selected,
|
||||
}: {
|
||||
avg: number | null;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (avg === null) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
selected ? "text-accent-foreground/80" : "text-muted-foreground",
|
||||
)}
|
||||
aria-label="未評価"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums",
|
||||
selected ? "text-accent-foreground" : "text-foreground/80",
|
||||
)}
|
||||
aria-label={`平均スコア ${avg.toFixed(1)} / 5`}
|
||||
>
|
||||
<Star aria-hidden className="size-3 fill-current" />
|
||||
{avg.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type DeleteConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
itemName: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
itemName,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
「{itemName}」を削除します。この操作は取り消せません。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>キャンセル</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>削除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { type Department } from "@/lib/schema";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { SettingsDialogContent } from "@/components/workspace/SettingsDialog";
|
||||
|
||||
type GlobalHeaderProps = {
|
||||
departmentTitle: string;
|
||||
positionTitle: string;
|
||||
candidateName: string;
|
||||
departments: Department[];
|
||||
onAddDepartment: (name: string) => void;
|
||||
onDeleteDepartment: (deptId: string) => void;
|
||||
};
|
||||
|
||||
export function GlobalHeader({
|
||||
departmentTitle,
|
||||
positionTitle,
|
||||
candidateName,
|
||||
departments,
|
||||
onAddDepartment,
|
||||
onDeleteDepartment,
|
||||
}: GlobalHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-3">
|
||||
<Breadcrumb
|
||||
className="min-w-0 flex-1 overflow-hidden"
|
||||
aria-label="パンくず"
|
||||
>
|
||||
<BreadcrumbList className="flex-nowrap text-[11px]">
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink>{departmentTitle}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink>{positionTitle}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate font-medium">
|
||||
{candidateName}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="ワークスペース設定"
|
||||
>
|
||||
<Settings />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">ワークスペース設定</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsDialogContent
|
||||
departments={departments}
|
||||
onAddDepartment={onAddDepartment}
|
||||
onDeleteDepartment={onDeleteDepartment}
|
||||
/>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Pane 1 折りたたみトグル。shadcn/ui の SidebarTrigger は単一の `PanelLeft`
|
||||
* アイコンを使うが、本プロジェクトでは「畳むのか / 開くのか」を一目で伝える
|
||||
* ために `PanelLeftClose` / `PanelLeftOpen` を `state` で切り替えている。
|
||||
*
|
||||
* - 表示位置: Pane 1 ヘッダー右端(展開時) / Pane 1 ヘッダー中央(畳時 = rail 上部)
|
||||
* - キーボード: Cmd+B / Ctrl+B(shadcn 標準、`SidebarProvider` 側で実装)
|
||||
* - shadcn 素体(`components/ui/sidebar.tsx`)は触らず、独自トグルとしてラップする
|
||||
*
|
||||
* 仕様の出典:
|
||||
* - openspec/decision/0006-... §5「Pane 1 折りたたみトグルは
|
||||
* `PanelLeftClose` / `PanelLeftOpen` を state で切替」(本実装で追記)
|
||||
*/
|
||||
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Pane1ToggleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Pane1Toggle({ className }: Pane1ToggleProps) {
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const isExpanded = state === "expanded";
|
||||
const Icon = isExpanded ? PanelLeftClose : PanelLeftOpen;
|
||||
const label = isExpanded ? "Pane 1 を閉じる" : "Pane 1 を開く";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={label}
|
||||
title={`${label}(⌘B)`}
|
||||
className={cn("size-7 text-sidebar-foreground", className)}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionLabel } from "@/components/primitives";
|
||||
|
||||
type Pane4SectionProps = {
|
||||
id?: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Pane4Section({
|
||||
id,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: Pane4SectionProps) {
|
||||
return (
|
||||
<section id={id} className={cn("flex flex-col gap-3 px-5 py-4", className)}>
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Pane4ToggleProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Pane4Toggle({ open, onToggle, className }: Pane4ToggleProps) {
|
||||
const Icon = open ? PanelRightClose : PanelRightOpen;
|
||||
const label = open ? "Pane 4 を閉じる" : "Pane 4 を開く";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
className={cn("size-7 text-foreground", className)}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="left">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { type Department } from "@/lib/schema";
|
||||
import { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Pane1Toggle } from "@/components/workspace/Pane1Toggle";
|
||||
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
|
||||
|
||||
type PositionPaneProps = {
|
||||
workspaceName: string;
|
||||
departments: Department[];
|
||||
selectedPositionName?: string;
|
||||
onAddPosition: (deptId: string, posName: string) => void;
|
||||
onDeletePosition: (deptId: string, posId: string) => void;
|
||||
};
|
||||
|
||||
export function PositionPane({
|
||||
workspaceName,
|
||||
departments,
|
||||
selectedPositionName,
|
||||
onAddPosition,
|
||||
onDeletePosition,
|
||||
}: PositionPaneProps) {
|
||||
const [addDialogDeptId, setAddDialogDeptId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
deptId: string;
|
||||
posId: string;
|
||||
posName: string;
|
||||
} | null>(null);
|
||||
|
||||
const addDialogDept = departments.find((d) => d.id === addDialogDeptId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="border-r border-sidebar-border [&_[data-slot=sidebar-container]]:bg-sidebar"
|
||||
>
|
||||
<SidebarHeader className="border-b border-sidebar-border p-0">
|
||||
<div className="flex h-12 items-center justify-between gap-2 px-3 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[state=expanded]:px-5">
|
||||
<h2 className="truncate text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
{workspaceName}
|
||||
</h2>
|
||||
<Pane1Toggle />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="px-1 py-3 group-data-[collapsible=icon]:hidden">
|
||||
{departments.map((dept) => (
|
||||
<SidebarGroup key={dept.id} className="px-1">
|
||||
<SidebarGroupLabel className="px-2 text-xs font-semibold tracking-wide text-sidebar-foreground/70 uppercase">
|
||||
{dept.name}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupAction
|
||||
title={`${dept.name} にポジションを追加`}
|
||||
onClick={() => setAddDialogDeptId(dept.id)}
|
||||
className="w-6 rounded-[min(var(--radius-md),10px)] text-muted-foreground hover:bg-muted hover:text-foreground [&>svg]:size-3"
|
||||
>
|
||||
<Plus />
|
||||
<span className="sr-only">{dept.name} にポジションを追加</span>
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{dept.positions.map((pos) => {
|
||||
const active = pos.name === selectedPositionName;
|
||||
return (
|
||||
<SidebarMenuItem key={pos.id}>
|
||||
<SidebarMenuButton
|
||||
tooltip={pos.name}
|
||||
isActive={active}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<span className="truncate">{pos.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{pos.count}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">操作</span>
|
||||
</SidebarMenuAction>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() =>
|
||||
setDeleteTarget({
|
||||
deptId: dept.id,
|
||||
posId: pos.id,
|
||||
posName: pos.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
削除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
{addDialogDept && (
|
||||
<AddItemDialog
|
||||
open={addDialogDeptId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAddDialogDeptId(null);
|
||||
}}
|
||||
title="ポジションを追加"
|
||||
description={`${addDialogDept.name} に新しいポジションを追加します`}
|
||||
fieldLabel="ポジション名"
|
||||
fieldId="pos-name"
|
||||
placeholder="例: データエンジニア"
|
||||
onAdd={(name) => onAddPosition(addDialogDept.id, name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
title="ポジションを削除しますか?"
|
||||
itemName={deleteTarget?.posName ?? ""}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
onDeletePosition(deleteTarget.deptId, deleteTarget.posId);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { type Department } from "@/lib/schema";
|
||||
import { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FieldGroup, Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupInput,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type SettingsDialogContentProps = {
|
||||
departments: Department[];
|
||||
onAddDepartment: (name: string) => void;
|
||||
onDeleteDepartment: (deptId: string) => void;
|
||||
};
|
||||
|
||||
export function SettingsDialogContent({
|
||||
departments,
|
||||
onAddDepartment,
|
||||
onDeleteDepartment,
|
||||
}: SettingsDialogContentProps) {
|
||||
const [newDeptName, setNewDeptName] = useState("");
|
||||
const [deleteDeptTarget, setDeleteDeptTarget] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleAddDept = () => {
|
||||
const trimmed = newDeptName.trim();
|
||||
if (!trimmed) return;
|
||||
onAddDepartment(trimmed);
|
||||
setNewDeptName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ワークスペース設定</DialogTitle>
|
||||
<DialogDescription>
|
||||
事業部やワークスペース名を管理します
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-new-dept">事業部</FieldLabel>
|
||||
<ScrollArea className="max-h-48">
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{departments.map((dept) => (
|
||||
<div
|
||||
key={dept.id}
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{dept.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setDeleteDeptTarget({ id: dept.id, name: dept.name })
|
||||
}
|
||||
aria-label={`${dept.name} を削除`}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{departments.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
事業部がありません
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="settings-new-dept"
|
||||
placeholder="新しい事業部名"
|
||||
value={newDeptName}
|
||||
onChange={(e) => setNewDeptName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddDept();
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDept}
|
||||
disabled={!newDeptName.trim()}
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
追加
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-workspace-name">
|
||||
ワークスペース名
|
||||
</FieldLabel>
|
||||
<Input id="settings-workspace-name" defaultValue="採用管理" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline">閉じる</Button>} />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDeptTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteDeptTarget(null);
|
||||
}}
|
||||
title="事業部を削除しますか?"
|
||||
itemName={deleteDeptTarget?.name ?? ""}
|
||||
onConfirm={() => {
|
||||
if (deleteDeptTarget) {
|
||||
onDeleteDepartment(deleteDeptTarget.id);
|
||||
setDeleteDeptTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"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 を本実装で改訂):
|
||||
*
|
||||
* ```
|
||||
* <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
|
||||
* §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 { 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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user