feat: Pane2にD&Dとアーカイブを追加

- Pane2 の候補者カードを D&D で並び替え・アーカイブできるように
- README のサンプル画面スクリーンショットを最新版に差し替え
This commit is contained in:
snc777 2026-05-09 16:12:21 +09:00
parent f6a781c47e
commit 7cb2209735
12 changed files with 887 additions and 142 deletions

View File

@ -64,3 +64,50 @@ describe("schema rejects invalid data", () => {
expect(workspaceSchema.safeParse({ icon: "" }).success).toBe(false);
});
});
describe("candidate.archived の取り扱い", () => {
const baseCandidate = {
id: "c-archived-test",
profile: {
name: "テスト 太郎",
birthday: "",
source: "",
email: "",
phone: "",
address: "",
recruiter: "",
desiredSalaryMin: "",
desiredSalaryMax: "",
availableStartDate: "",
careerText: "",
motivationFull: "",
},
scorecards: [],
stage: "screening" as const,
};
it("archived 未指定なら false がデフォルトで埋まる", () => {
const result = candidatesSchema.safeParse([baseCandidate]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data[0].archived).toBe(false);
}
});
it("archived: true を許容する", () => {
const result = candidatesSchema.safeParse([
{ ...baseCandidate, archived: true },
]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data[0].archived).toBe(true);
}
});
it("archived が boolean でなければ不可", () => {
const result = candidatesSchema.safeParse([
{ ...baseCandidate, archived: "yes" },
]);
expect(result.success).toBe(false);
});
});

View File

@ -104,6 +104,7 @@ describe("getCandidateAverageScore / getScorecardsAverageScore", () => {
},
scorecards: [],
stage: "screening",
archived: false,
};
expect(getCandidateAverageScore(candidate)).toBeNull();
expect(getScorecardsAverageScore([])).toBeNull();

View File

@ -1,14 +1,50 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Star, Trash2 } from "lucide-react";
import {
Archive,
ArchiveRestore,
ChevronDown,
MoreHorizontal,
Plus,
Star,
} from "lucide-react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useDroppable,
useSensor,
useSensors,
type Announcements,
type DragEndEvent,
type DragStartEvent,
type ScreenReaderInstructions,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import { type Group, type StageKey } from "@/lib/schema";
import {
type CandidateRow,
type Group,
type StageKey,
} from "@/lib/schema";
import { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
import { SortableCandidateRow } from "@/components/workspace/SortableCandidateRow";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
@ -18,13 +54,22 @@ import {
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
import { STAGE_LABELS } from "@/lib/labels";
// dnd-kit のスクリーンリーダー向け日本語化。
const screenReaderInstructions: ScreenReaderInstructions = {
draggable:
"Space または Enter で候補者を持ち上げ、矢印キーで移動、Space で確定、Esc でキャンセルします。",
};
type CandidateListPaneProps = {
groups: Group[];
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onAddCandidate: (stage: StageKey, name: string) => void;
onDeleteCandidate: (id: string) => void;
onArchiveCandidate: (id: string) => void;
onRestoreCandidate: (id: string) => void;
onMoveCandidate: (id: string, toStage: StageKey, toIndex: number) => void;
};
export function CandidateListPane({
@ -32,16 +77,124 @@ export function CandidateListPane({
selectedCandidateId,
onSelectCandidate,
onAddCandidate,
onDeleteCandidate,
onArchiveCandidate,
onRestoreCandidate,
onMoveCandidate,
}: CandidateListPaneProps) {
const [addDialogStage, setAddDialogStage] = useState<{
stage: StageKey;
label: string;
} | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
const [archiveTarget, setArchiveTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [archivedOpen, setArchivedOpen] = useState(false);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
// PointerSensor の distance 制約で「クリック」と「ドラッグ」を区別する
// 6px 以上動かさないとドラッグ起動しない → 行クリック / メニュー操作と衝突しない)。
// KeyboardSensor は Tab 移動 → Space で持ち上げ → 矢印で移動 → Enter で確定。
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const stageGroups = groups.filter(
(g): g is Extract<Group, { kind: "stage" }> => g.kind === "stage",
);
const archivedGroup = groups.find(
(g): g is Extract<Group, { kind: "archived" }> => g.kind === "archived",
);
// ドラッグ中の浮遊行表示用に元データを引く
const activeDragRow: { row: CandidateRow; stage: StageKey } | null = (() => {
if (!activeDragId) return null;
for (const g of stageGroups) {
const row = g.items.find((r) => r.id === activeDragId);
if (row) return { row, stage: g.stage };
}
return null;
})();
const announcements: Announcements = {
onDragStart: ({ active }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
return `${name}を持ち上げました。`;
},
onDragOver: ({ active, over }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
if (!over) return `${name}を移動中です。`;
const overContainer = over.data.current?.containerId as
| StageKey
| undefined;
if (overContainer)
return `${name}を「${STAGE_LABELS[overContainer]}」の上に移動しました。`;
return `${name}を移動中です。`;
},
onDragEnd: ({ active, over }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
if (!over) return `${name}の移動をキャンセルしました。`;
const overContainer =
(over.data.current?.containerId as StageKey | undefined) ??
(typeof over.id === "string" &&
stageGroups.some((g) => g.stage === over.id)
? (over.id as StageKey)
: undefined);
if (!overContainer) return `${name}を確定しました。`;
return `${name}を「${STAGE_LABELS[overContainer]}」に移動しました。`;
},
onDragCancel: ({ active }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
return `${name}の移動をキャンセルしました。`;
},
};
const handleDragStart = (event: DragStartEvent) => {
setActiveDragId(String(event.active.id));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
if (!over) return;
const activeContainer = active.data.current?.containerId as
| StageKey
| undefined;
// over の所属コンテナは、要素ドロップなら data.current.containerId、
// SortableContext そのものへのドロップなら over.id= "stage" のキー)。
const overContainer =
(over.data.current?.containerId as StageKey | undefined) ??
(typeof over.id === "string" &&
stageGroups.some((g) => g.stage === over.id)
? (over.id as StageKey)
: undefined);
if (!activeContainer || !overContainer) return;
const targetGroup = stageGroups.find((g) => g.stage === overContainer);
if (!targetGroup) return;
let toIndex: number;
if (active.id === over.id) return;
const overIndexInTarget = targetGroup.items.findIndex(
(r) => r.id === over.id,
);
if (overIndexInTarget >= 0) {
// 同コンテナ内: active を一旦取り除いた後の index を保つため、
// active が同コンテナ内で over より前にあれば overIndexInTarget はそのまま、
// 後ろなら -1 ずれる、という自然な算出になる。Workspace.moveCandidate 側で
// active を除いた配列上で挿入するため、ここでは単純に over の現 index を渡す。
toIndex = overIndexInTarget;
} else {
// 空コンテナ・末尾領域へのドロップ: 末尾に追加
toIndex = targetGroup.items.length;
}
onMoveCandidate(String(active.id), overContainer, toIndex);
};
return (
<section className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background">
@ -51,42 +204,276 @@ export function CandidateListPane({
</h2>
</header>
<ScrollArea className="min-h-0 flex-1">
<DndContext
// 固定 id を渡して SSR/CSR 間の `aria-describedby` 採番ズレ
// DndDescribedBy-N の連番)による hydration mismatch を回避する
id="pane2-candidate-dnd"
sensors={sensors}
collisionDetection={closestCenter}
accessibility={{ announcements, screenReaderInstructions }}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveDragId(null)}
>
<div className="flex flex-col gap-5 px-3 py-4">
{groups.map((group) => (
<div key={group.stage}>
{stageGroups.map((group) => (
<StageGroup
key={`stage:${group.stage}`}
stage={group.stage}
label={group.label}
items={group.items}
selectedCandidateId={selectedCandidateId}
onSelectCandidate={onSelectCandidate}
onAddRequest={() =>
setAddDialogStage({
stage: group.stage,
label: group.label,
})
}
onArchiveRequest={(id, name) =>
setArchiveTarget({ id, name })
}
/>
))}
{archivedGroup && (
<ArchivedGroup
label={archivedGroup.label}
items={archivedGroup.items}
open={archivedOpen}
onOpenChange={setArchivedOpen}
selectedCandidateId={selectedCandidateId}
onSelectCandidate={onSelectCandidate}
onRestore={onRestoreCandidate}
/>
)}
</div>
<DragOverlay>
{activeDragRow && (
<div className="flex items-center gap-2 rounded-md bg-accent px-2.5 py-2.5 text-accent-foreground shadow-lg">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-xs text-primary">
{activeDragRow.row.name[0] ?? "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{activeDragRow.row.name}</p>
</div>
</div>
)}
</DragOverlay>
</DndContext>
</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={archiveTarget !== null}
onOpenChange={(open) => {
if (!open) setArchiveTarget(null);
}}
title="候補者をアーカイブしますか?"
itemName={archiveTarget?.name ?? ""}
description={`${archiveTarget?.name ?? ""}」をアーカイブします。後で「アーカイブ済み」から復元できます。`}
actionLabel="アーカイブ"
onConfirm={() => {
if (archiveTarget) {
onArchiveCandidate(archiveTarget.id);
setArchiveTarget(null);
}
}}
/>
</section>
);
}
function StageGroup({
stage,
label,
items,
selectedCandidateId,
onSelectCandidate,
onAddRequest,
onArchiveRequest,
}: {
stage: StageKey;
label: string;
items: CandidateRow[];
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onAddRequest: () => void;
onArchiveRequest: (id: string, name: string) => void;
}) {
// 空ステージでもドロップを受け取れるようにする(最後の 1 名を別ステージへ
// 動かした後の戻し先を保つため、ステージは常時表示)。
// SortableContext id (= stage) は要素の並び替え用、useDroppable id は
// 「コンテナ自体」の drop ターゲット用。両者を分けることで衝突を避ける。
const { setNodeRef, isOver } = useDroppable({
id: `dropzone:${stage}`,
data: { containerId: stage },
});
return (
<div>
<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}
{label}
</h3>
<Badge variant="secondary" size="xs">
{group.items.length}
{items.length}
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() =>
setAddDialogStage({
stage: group.stage,
label: group.label,
})
}
aria-label={`${group.label} に候補者を追加`}
onClick={onAddRequest}
aria-label={`${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;
<SortableContext
id={stage}
items={items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<ul
ref={setNodeRef}
data-stage={stage}
className={cn(
"flex flex-col gap-1",
items.length === 0 && "min-h-12 rounded-md border border-dashed border-border/70 p-2",
items.length === 0 && isOver && "border-primary/60 bg-primary/5",
)}
>
{items.length === 0 ? (
<li
className={cn(
"pointer-events-none flex h-8 items-center justify-center text-xs",
isOver ? "text-primary" : "text-muted-foreground",
)}
aria-hidden="true"
>
</li>
) : (
items.map((cand) => (
<SortableCandidateRow
key={cand.id}
cand={cand}
stage={stage}
selected={cand.id === selectedCandidateId}
onSelect={onSelectCandidate}
actions={
<DropdownMenuItem
variant="destructive"
onSelect={() => onArchiveRequest(cand.id, cand.name)}
>
<Archive />
</DropdownMenuItem>
}
/>
))
)}
</ul>
</SortableContext>
</div>
);
}
function ArchivedGroup({
label,
items,
open,
onOpenChange,
selectedCandidateId,
onSelectCandidate,
onRestore,
}: {
label: string;
items: CandidateRow[];
open: boolean;
onOpenChange: (open: boolean) => void;
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onRestore: (id: string) => void;
}) {
return (
<li key={cand.id} className="group/candidate relative">
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger
nativeButton={false}
render={
<div
className={cn(
"group/archived-trigger sticky top-0 z-10 -mx-3 mb-2 flex cursor-pointer items-center justify-between gap-2 bg-background px-5 py-1.5",
"rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
)}
/>
}
>
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-xs font-medium text-muted-foreground">
{label}
</h3>
<Badge variant="secondary" size="xs">
{items.length}
</Badge>
</div>
<ChevronDown
aria-hidden="true"
className="size-4 text-muted-foreground transition-[color,transform] group-hover/archived-trigger:text-foreground in-data-[panel-open]:rotate-180"
/>
<span className="sr-only">{`${label}を開く`}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="flex flex-col gap-1" data-stage="archived">
{items.map((cand) => (
<ArchivedRowItem
key={cand.id}
cand={cand}
selected={cand.id === selectedCandidateId}
onSelect={onSelectCandidate}
onRestore={onRestore}
/>
))}
</ul>
</CollapsibleContent>
</Collapsible>
);
}
function ArchivedRowItem({
cand,
selected,
onSelect,
onRestore,
}: {
cand: CandidateRow;
selected: boolean;
onSelect: (id: string) => void;
onRestore: (id: string) => void;
}) {
return (
<li className="group/candidate relative">
<button
type="button"
onClick={() => onSelectCandidate(cand.id)}
onClick={() => onSelect(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",
@ -104,10 +491,7 @@ export function CandidateListPane({
<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}
/>
<ScoreBadge avg={cand.averageScore} selected={selected} />
</span>
</button>
<DropdownMenu>
@ -131,61 +515,15 @@ export function CandidateListPane({
/>
<DropdownMenuContent side="right" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
onSelect={() =>
setDeleteTarget({
id: cand.id,
name: cand.name,
})
}
>
<Trash2 />
<DropdownMenuItem onSelect={() => onRestore(cand.id)}>
<ArchiveRestore />
</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({

View File

@ -17,6 +17,11 @@ type DeleteConfirmDialogProps = {
title: string;
itemName: string;
onConfirm: () => void;
/** {itemName}
* */
description?: string;
/** 既定は「削除」。アーカイブ等で別ラベルにしたい場合に上書きする。 */
actionLabel?: string;
};
export function DeleteConfirmDialog({
@ -25,6 +30,8 @@ export function DeleteConfirmDialog({
title,
itemName,
onConfirm,
description,
actionLabel = "削除",
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
@ -32,12 +39,12 @@ export function DeleteConfirmDialog({
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{itemName}
{description ?? `${itemName}」を削除します。この操作は取り消せません。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}></AlertDialogAction>
<AlertDialogAction onClick={onConfirm}>{actionLabel}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -0,0 +1,166 @@
"use client";
import { type CSSProperties, type ReactNode } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreHorizontal, Star } from "lucide-react";
import { cn } from "@/lib/utils";
import { type CandidateRow, type StageKey } from "@/lib/schema";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Pane 2
*
* - = onSelect
* - drag listener `MoreHorizontal`
* `activationConstraint: distance`
* - DragOverlay `isDragging` + pointer-events
*
* archived stage
* archived CandidateListPane `<li>`
*/
export function SortableCandidateRow({
cand,
stage,
selected,
onSelect,
actions,
}: {
cand: CandidateRow;
stage: StageKey;
selected: boolean;
onSelect: (id: string) => void;
actions: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: cand.id,
data: { containerId: stage, name: cand.name },
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
className={cn(
"group/candidate relative",
isDragging && "pointer-events-none opacity-50",
)}
>
<button
type="button"
onClick={() => onSelect(cand.id)}
className={cn(
"flex w-full items-center gap-2 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",
)}
>
<span
{...attributes}
{...listeners}
aria-label={`${cand.name} の並び替え`}
className={cn(
"flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground",
"opacity-0 transition-opacity group-focus-within/candidate:opacity-100 group-hover/candidate:opacity-100",
"hover:text-foreground active:cursor-grabbing",
"outline-none focus-visible:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50",
)}
// ドラッグハンドルだけのクリックで選択動作が走らないようにする
onClick={(e) => e.stopPropagation()}
>
<GripVertical aria-hidden="true" className="size-4" />
</span>
<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>{actions}</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
}
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>
);
}

View File

@ -57,7 +57,7 @@ import {
createMinimalScorecard,
} from "@/lib/data/factories";
import { getCandidateAverageScore } from "@/lib/computed/scorecards";
import { STAGE_LABELS } from "@/lib/labels";
import { ARCHIVED_GROUP_LABEL, STAGE_LABELS } from "@/lib/labels";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { GlobalHeader } from "@/components/workspace/GlobalHeader";
import { PositionPane } from "@/components/workspace/PositionPane";
@ -186,6 +186,7 @@ export function Workspace({
profile: createMinimalProfile(name),
scorecards: [],
stage,
archived: false,
};
setCandidates((prev) => [...prev, newCandidate]);
setSelectedCandidateId(newId);
@ -194,17 +195,75 @@ export function Workspace({
setPane4ManuallyClosed(false);
}, []);
const deleteCandidate = useCallback((id: string) => {
// 候補者をアーカイブ(論理削除)する。データは残し `archived: true` を立てる。
// 復元は `restoreCandidate` から、もしくは Pane 2「アーカイブ済み」グループの
// 「復元」ボタン経由。アクティブ候補者をアーカイブした場合は、非 archived の
// 先頭候補者にフォールバックし、ステージ詳細Pane 4はクリアする。
const archiveCandidate = useCallback((id: string) => {
setCandidates((prev) => {
const next = prev.filter((c) => c.id !== id);
const next = prev.map((c) =>
c.id === id ? { ...c, archived: true } : c,
);
setSelectedCandidateId((prevId) => {
if (prevId !== id) return prevId;
return next.length > 0 ? next[0].id : "";
const fallback = next.find((c) => !c.archived);
return fallback ? fallback.id : "";
});
return next;
});
setSelectedDetail(null);
setPane4ManuallyClosed(false);
}, []);
// アーカイブ済み候補者を元のステージに復元する。`stage` は archived 中も保持
// しているので、そのステージへ戻すだけでよい。
const restoreCandidate = useCallback((id: string) => {
setCandidates((prev) =>
prev.map((c) => (c.id === id ? { ...c, archived: false } : c)),
);
}, []);
// 候補者を別ステージへ移動 / 同ステージ内で並び替え。
//
// `toStage` は移動先のステージキー。`toIndex` はそのステージグループ内での
// 0-origin の挿入位置。配列順を SSoT としているため、candidates 配列上の
// 絶対インデックスに変換して `splice` 相当の挿入を行う。
//
// 同ステージ内ドラッグ・別ステージへのドラッグの両方をこの 1 関数で扱う。
// archived 候補者は対象外DnD はアクティブな候補者のみ可能)。
const moveCandidate = useCallback(
(id: string, toStage: StageKey, toIndex: number) => {
setCandidates((prev) => {
const subjectIndex = prev.findIndex((c) => c.id === id);
if (subjectIndex < 0) return prev;
const subject = prev[subjectIndex];
if (subject.archived) return prev;
const without = prev.filter((_, i) => i !== subjectIndex);
const updated: Candidate = { ...subject, stage: toStage };
let count = 0;
let absInsertAt = without.length;
for (let i = 0; i < without.length; i++) {
const c = without[i];
if (!c.archived && c.stage === toStage) {
if (count === toIndex) {
absInsertAt = i;
break;
}
count++;
}
}
return [
...without.slice(0, absInsertAt),
updated,
...without.slice(absInsertAt),
];
});
},
[],
);
const addDepartment = useCallback((name: string) => {
setDepartments((prev) => [
...prev,
@ -301,21 +360,37 @@ export function Workspace({
const positionTitle = "フロントエンドエンジニア";
const departmentTitle = "プロダクト開発";
const candidateGroups: Group[] = useMemo(
() =>
STAGE_ORDER.map((stage) => ({
const candidateGroups: Group[] = useMemo(() => {
// ステージグループは常に 4 段階すべて表示する。空ステージも残すことで、
// 「最後の 1 名を別ステージへ動かしたら戻し先が消える」事故を防ぐ
// ADR-006 §2-2 の補足)。
const stageGroups: Group[] = STAGE_ORDER.map((stage) => ({
kind: "stage" as const,
stage,
label: STAGE_LABELS[stage],
items: candidates
.filter((c) => c.stage === stage)
.filter((c) => !c.archived && c.stage === stage)
.map((c) => ({
id: c.id,
name: c.profile.name,
averageScore: getCandidateAverageScore(c),
})),
})).filter((g) => g.items.length > 0),
[candidates],
);
}));
const archivedItems = candidates
.filter((c) => c.archived)
.map((c) => ({
id: c.id,
name: c.profile.name,
averageScore: getCandidateAverageScore(c),
}));
if (archivedItems.length === 0) return stageGroups;
return [
...stageGroups,
{ kind: "archived" as const, label: ARCHIVED_GROUP_LABEL, items: archivedItems },
];
}, [candidates]);
return (
// shadcn/ui の SidebarProvider が外側を取り、Pane 1 (`<Sidebar>`) を全高で固定
@ -352,7 +427,9 @@ export function Workspace({
selectedCandidateId={selectedCandidateId}
onSelectCandidate={selectCandidate}
onAddCandidate={addCandidate}
onDeleteCandidate={deleteCandidate}
onArchiveCandidate={archiveCandidate}
onRestoreCandidate={restoreCandidate}
onMoveCandidate={moveCandidate}
/>
<CandidateDashboardPane
profile={profile}

View File

@ -202,5 +202,45 @@
},
"scorecards": [],
"stage": "final"
},
{
"id": "c7",
"profile": {
"name": "中村 聡",
"birthday": "",
"source": "",
"email": "",
"phone": "",
"address": "",
"recruiter": "",
"desiredSalaryMin": "",
"desiredSalaryMax": "",
"availableStartDate": "",
"careerText": "",
"motivationFull": ""
},
"scorecards": [],
"stage": "first",
"archived": true
},
{
"id": "c8",
"profile": {
"name": "小林 結衣",
"birthday": "",
"source": "",
"email": "",
"phone": "",
"address": "",
"recruiter": "",
"desiredSalaryMin": "",
"desiredSalaryMax": "",
"availableStartDate": "",
"careerText": "",
"motivationFull": ""
},
"scorecards": [],
"stage": "screening",
"archived": true
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 456 KiB

View File

@ -32,6 +32,10 @@ export const STAGE_LABELS: Record<StageKey, string> = {
final: "最終面接",
};
// Pane 2 末尾の「アーカイブ済み」グループの見出しラベル。
// archived === true の候補者を束ねる仮想グループで、ステージとは直交した概念。
export const ARCHIVED_GROUP_LABEL = "アーカイブ済み";
// ===== Pane 3 ダッシュボードのセクション見出しADR-0014 =====
export const PANE3_SECTION = {

View File

@ -164,6 +164,11 @@ export const candidateSchema = z.object({
profile: profileSchema,
scorecards: z.array(scorecardSchema),
stage: stageKeySchema,
// 論理削除フラグ。`stage` とは直交する。アーカイブされた候補者は通常のステージ
// グループから外れ、Pane 2 末尾の「アーカイブ済み」グループに表示される。
// 復元時は `stage` をそのまま使って元のステージへ戻る。JSON シードでは省略可
// `.default(false)` で読み込み時に補完)。
archived: z.boolean().default(false),
});
export type Candidate = z.infer<typeof candidateSchema>;
@ -203,14 +208,15 @@ export type CandidateRow = {
averageScore: number | null;
};
// Pane 2 のグループ表示単位(ステージごとに候補者行を束ねる)。
// Pane 2 のグループ表示単位(ステージ or アーカイブ済み)。
// 候補者データは `INITIAL_CANDIDATES` を SSoT とし、`candidateGroups` で
// 派生計算して CandidateListPane に props で渡すWorkspace 内で計算)。
//
// stage: StageKey は「+ ボタン」から `addCandidate(stage)` を呼ぶときの
// 引数に使う(型安全に StageKey を渡すため)。日本語ラベルは label に分離。
export type Group = {
stage: StageKey;
label: string;
items: CandidateRow[];
};
// `kind: "stage"` は通常の選考ステージグループ。`stage: StageKey` は
// 「+ ボタン」から `addCandidate(stage)` を呼ぶときの引数に使う。
// `kind: "archived"` は archived === true の候補者を集めた末尾の仮想グループで、
// 「+ 追加」操作は持たず、復元のみ可能。並び順は `kind: "stage"` を STAGE_ORDER で
// 並べた後、最後に `kind: "archived"` を 1 つだけ置く(要素があるときのみ表示)。
export type Group =
| { kind: "stage"; stage: StageKey; label: string; items: CandidateRow[] }
| { kind: "archived"; label: string; items: CandidateRow[] };

56
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -735,6 +738,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.65.0.tgz",

View File

@ -15,6 +15,9 @@
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",