feat: Pane2にD&Dとアーカイブを追加
- Pane2 の候補者カードを D&D で並び替え・アーカイブできるように - README のサンプル画面スクリーンショットを最新版に差し替え
This commit is contained in:
parent
f6a781c47e
commit
7cb2209735
@ -64,3 +64,50 @@ describe("schema rejects invalid data", () => {
|
|||||||
expect(workspaceSchema.safeParse({ icon: "" }).success).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -104,6 +104,7 @@ describe("getCandidateAverageScore / getScorecardsAverageScore", () => {
|
|||||||
},
|
},
|
||||||
scorecards: [],
|
scorecards: [],
|
||||||
stage: "screening",
|
stage: "screening",
|
||||||
|
archived: false,
|
||||||
};
|
};
|
||||||
expect(getCandidateAverageScore(candidate)).toBeNull();
|
expect(getCandidateAverageScore(candidate)).toBeNull();
|
||||||
expect(getScorecardsAverageScore([])).toBeNull();
|
expect(getScorecardsAverageScore([])).toBeNull();
|
||||||
|
|||||||
@ -1,14 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { 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 { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
|
||||||
|
import { SortableCandidateRow } from "@/components/workspace/SortableCandidateRow";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -18,13 +54,22 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
|
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
|
||||||
|
import { STAGE_LABELS } from "@/lib/labels";
|
||||||
|
|
||||||
|
// dnd-kit のスクリーンリーダー向け日本語化。
|
||||||
|
const screenReaderInstructions: ScreenReaderInstructions = {
|
||||||
|
draggable:
|
||||||
|
"Space または Enter で候補者を持ち上げ、矢印キーで移動、Space で確定、Esc でキャンセルします。",
|
||||||
|
};
|
||||||
|
|
||||||
type CandidateListPaneProps = {
|
type CandidateListPaneProps = {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
selectedCandidateId: string;
|
selectedCandidateId: string;
|
||||||
onSelectCandidate: (id: string) => void;
|
onSelectCandidate: (id: string) => void;
|
||||||
onAddCandidate: (stage: StageKey, name: 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({
|
export function CandidateListPane({
|
||||||
@ -32,16 +77,124 @@ export function CandidateListPane({
|
|||||||
selectedCandidateId,
|
selectedCandidateId,
|
||||||
onSelectCandidate,
|
onSelectCandidate,
|
||||||
onAddCandidate,
|
onAddCandidate,
|
||||||
onDeleteCandidate,
|
onArchiveCandidate,
|
||||||
|
onRestoreCandidate,
|
||||||
|
onMoveCandidate,
|
||||||
}: CandidateListPaneProps) {
|
}: CandidateListPaneProps) {
|
||||||
const [addDialogStage, setAddDialogStage] = useState<{
|
const [addDialogStage, setAddDialogStage] = useState<{
|
||||||
stage: StageKey;
|
stage: StageKey;
|
||||||
label: string;
|
label: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [archiveTarget, setArchiveTarget] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | 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 (
|
return (
|
||||||
<section className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background">
|
<section className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background">
|
||||||
@ -51,108 +204,64 @@ export function CandidateListPane({
|
|||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="flex flex-col gap-5 px-3 py-4">
|
<DndContext
|
||||||
{groups.map((group) => (
|
// 固定 id を渡して SSR/CSR 間の `aria-describedby` 採番ズレ
|
||||||
<div key={group.stage}>
|
// (DndDescribedBy-N の連番)による hydration mismatch を回避する
|
||||||
<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">
|
id="pane2-candidate-dnd"
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
sensors={sensors}
|
||||||
<h3 className="truncate text-xs font-medium text-muted-foreground">
|
collisionDetection={closestCenter}
|
||||||
{group.label}
|
accessibility={{ announcements, screenReaderInstructions }}
|
||||||
</h3>
|
onDragStart={handleDragStart}
|
||||||
<Badge variant="secondary" size="xs">
|
onDragEnd={handleDragEnd}
|
||||||
{group.items.length}
|
onDragCancel={() => setActiveDragId(null)}
|
||||||
</Badge>
|
>
|
||||||
|
<div className="flex flex-col gap-5 px-3 py-4">
|
||||||
|
{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>
|
||||||
<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>
|
</div>
|
||||||
<ul className="flex flex-col gap-1">
|
)}
|
||||||
{group.items.map((cand) => {
|
</DragOverlay>
|
||||||
const selected = cand.id === selectedCandidateId;
|
</DndContext>
|
||||||
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>
|
</ScrollArea>
|
||||||
|
|
||||||
{addDialogStage && (
|
{addDialogStage && (
|
||||||
@ -171,16 +280,18 @@ export function CandidateListPane({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={archiveTarget !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeleteTarget(null);
|
if (!open) setArchiveTarget(null);
|
||||||
}}
|
}}
|
||||||
title="候補者を削除しますか?"
|
title="候補者をアーカイブしますか?"
|
||||||
itemName={deleteTarget?.name ?? ""}
|
itemName={archiveTarget?.name ?? ""}
|
||||||
|
description={`「${archiveTarget?.name ?? ""}」をアーカイブします。後で「アーカイブ済み」から復元できます。`}
|
||||||
|
actionLabel="アーカイブ"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteTarget) {
|
if (archiveTarget) {
|
||||||
onDeleteCandidate(deleteTarget.id);
|
onArchiveCandidate(archiveTarget.id);
|
||||||
setDeleteTarget(null);
|
setArchiveTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -188,6 +299,233 @@ export function CandidateListPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" size="xs">
|
||||||
|
{items.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onAddRequest}
|
||||||
|
aria-label={`${label} に候補者を追加`}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<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 (
|
||||||
|
<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={() => 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",
|
||||||
|
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 onSelect={() => onRestore(cand.id)}>
|
||||||
|
<ArchiveRestore />
|
||||||
|
復元
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScoreBadge({
|
function ScoreBadge({
|
||||||
avg,
|
avg,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@ -17,6 +17,11 @@ type DeleteConfirmDialogProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
itemName: string;
|
itemName: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
/** 既定は「『{itemName}』を削除します。この操作は取り消せません。」。
|
||||||
|
* 論理削除(アーカイブ)など別文言が必要な呼び出し元のために上書きできる。 */
|
||||||
|
description?: string;
|
||||||
|
/** 既定は「削除」。アーカイブ等で別ラベルにしたい場合に上書きする。 */
|
||||||
|
actionLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeleteConfirmDialog({
|
export function DeleteConfirmDialog({
|
||||||
@ -25,6 +30,8 @@ export function DeleteConfirmDialog({
|
|||||||
title,
|
title,
|
||||||
itemName,
|
itemName,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
description,
|
||||||
|
actionLabel = "削除",
|
||||||
}: DeleteConfirmDialogProps) {
|
}: DeleteConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
@ -32,12 +39,12 @@ export function DeleteConfirmDialog({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
「{itemName}」を削除します。この操作は取り消せません。
|
{description ?? `「${itemName}」を削除します。この操作は取り消せません。`}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>キャンセル</AlertDialogCancel>
|
<AlertDialogCancel>キャンセル</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={onConfirm}>削除</AlertDialogAction>
|
<AlertDialogAction onClick={onConfirm}>{actionLabel}</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
166
components/workspace/SortableCandidateRow.tsx
Normal file
166
components/workspace/SortableCandidateRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -57,7 +57,7 @@ import {
|
|||||||
createMinimalScorecard,
|
createMinimalScorecard,
|
||||||
} from "@/lib/data/factories";
|
} from "@/lib/data/factories";
|
||||||
import { getCandidateAverageScore } from "@/lib/computed/scorecards";
|
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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { GlobalHeader } from "@/components/workspace/GlobalHeader";
|
import { GlobalHeader } from "@/components/workspace/GlobalHeader";
|
||||||
import { PositionPane } from "@/components/workspace/PositionPane";
|
import { PositionPane } from "@/components/workspace/PositionPane";
|
||||||
@ -186,6 +186,7 @@ export function Workspace({
|
|||||||
profile: createMinimalProfile(name),
|
profile: createMinimalProfile(name),
|
||||||
scorecards: [],
|
scorecards: [],
|
||||||
stage,
|
stage,
|
||||||
|
archived: false,
|
||||||
};
|
};
|
||||||
setCandidates((prev) => [...prev, newCandidate]);
|
setCandidates((prev) => [...prev, newCandidate]);
|
||||||
setSelectedCandidateId(newId);
|
setSelectedCandidateId(newId);
|
||||||
@ -194,17 +195,75 @@ export function Workspace({
|
|||||||
setPane4ManuallyClosed(false);
|
setPane4ManuallyClosed(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteCandidate = useCallback((id: string) => {
|
// 候補者をアーカイブ(論理削除)する。データは残し `archived: true` を立てる。
|
||||||
|
// 復元は `restoreCandidate` から、もしくは Pane 2「アーカイブ済み」グループの
|
||||||
|
// 「復元」ボタン経由。アクティブ候補者をアーカイブした場合は、非 archived の
|
||||||
|
// 先頭候補者にフォールバックし、ステージ詳細(Pane 4)はクリアする。
|
||||||
|
const archiveCandidate = useCallback((id: string) => {
|
||||||
setCandidates((prev) => {
|
setCandidates((prev) => {
|
||||||
const next = prev.filter((c) => c.id !== id);
|
const next = prev.map((c) =>
|
||||||
|
c.id === id ? { ...c, archived: true } : c,
|
||||||
|
);
|
||||||
setSelectedCandidateId((prevId) => {
|
setSelectedCandidateId((prevId) => {
|
||||||
if (prevId !== id) return 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;
|
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) => {
|
const addDepartment = useCallback((name: string) => {
|
||||||
setDepartments((prev) => [
|
setDepartments((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@ -301,21 +360,37 @@ export function Workspace({
|
|||||||
const positionTitle = "フロントエンドエンジニア";
|
const positionTitle = "フロントエンドエンジニア";
|
||||||
const departmentTitle = "プロダクト開発";
|
const departmentTitle = "プロダクト開発";
|
||||||
|
|
||||||
const candidateGroups: Group[] = useMemo(
|
const candidateGroups: Group[] = useMemo(() => {
|
||||||
() =>
|
// ステージグループは常に 4 段階すべて表示する。空ステージも残すことで、
|
||||||
STAGE_ORDER.map((stage) => ({
|
// 「最後の 1 名を別ステージへ動かしたら戻し先が消える」事故を防ぐ
|
||||||
stage,
|
// (ADR-006 §2-2 の補足)。
|
||||||
label: STAGE_LABELS[stage],
|
const stageGroups: Group[] = STAGE_ORDER.map((stage) => ({
|
||||||
items: candidates
|
kind: "stage" as const,
|
||||||
.filter((c) => c.stage === stage)
|
stage,
|
||||||
.map((c) => ({
|
label: STAGE_LABELS[stage],
|
||||||
id: c.id,
|
items: candidates
|
||||||
name: c.profile.name,
|
.filter((c) => !c.archived && c.stage === stage)
|
||||||
averageScore: getCandidateAverageScore(c),
|
.map((c) => ({
|
||||||
})),
|
id: c.id,
|
||||||
})).filter((g) => g.items.length > 0),
|
name: c.profile.name,
|
||||||
[candidates],
|
averageScore: getCandidateAverageScore(c),
|
||||||
);
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
// shadcn/ui の SidebarProvider が外側を取り、Pane 1 (`<Sidebar>`) を全高で固定
|
// shadcn/ui の SidebarProvider が外側を取り、Pane 1 (`<Sidebar>`) を全高で固定
|
||||||
@ -352,7 +427,9 @@ export function Workspace({
|
|||||||
selectedCandidateId={selectedCandidateId}
|
selectedCandidateId={selectedCandidateId}
|
||||||
onSelectCandidate={selectCandidate}
|
onSelectCandidate={selectCandidate}
|
||||||
onAddCandidate={addCandidate}
|
onAddCandidate={addCandidate}
|
||||||
onDeleteCandidate={deleteCandidate}
|
onArchiveCandidate={archiveCandidate}
|
||||||
|
onRestoreCandidate={restoreCandidate}
|
||||||
|
onMoveCandidate={moveCandidate}
|
||||||
/>
|
/>
|
||||||
<CandidateDashboardPane
|
<CandidateDashboardPane
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
|||||||
@ -202,5 +202,45 @@
|
|||||||
},
|
},
|
||||||
"scorecards": [],
|
"scorecards": [],
|
||||||
"stage": "final"
|
"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 |
@ -32,6 +32,10 @@ export const STAGE_LABELS: Record<StageKey, string> = {
|
|||||||
final: "最終面接",
|
final: "最終面接",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pane 2 末尾の「アーカイブ済み」グループの見出しラベル。
|
||||||
|
// archived === true の候補者を束ねる仮想グループで、ステージとは直交した概念。
|
||||||
|
export const ARCHIVED_GROUP_LABEL = "アーカイブ済み";
|
||||||
|
|
||||||
// ===== Pane 3 ダッシュボードのセクション見出し(ADR-0014) =====
|
// ===== Pane 3 ダッシュボードのセクション見出し(ADR-0014) =====
|
||||||
|
|
||||||
export const PANE3_SECTION = {
|
export const PANE3_SECTION = {
|
||||||
|
|||||||
@ -164,6 +164,11 @@ export const candidateSchema = z.object({
|
|||||||
profile: profileSchema,
|
profile: profileSchema,
|
||||||
scorecards: z.array(scorecardSchema),
|
scorecards: z.array(scorecardSchema),
|
||||||
stage: stageKeySchema,
|
stage: stageKeySchema,
|
||||||
|
// 論理削除フラグ。`stage` とは直交する。アーカイブされた候補者は通常のステージ
|
||||||
|
// グループから外れ、Pane 2 末尾の「アーカイブ済み」グループに表示される。
|
||||||
|
// 復元時は `stage` をそのまま使って元のステージへ戻る。JSON シードでは省略可
|
||||||
|
// (`.default(false)` で読み込み時に補完)。
|
||||||
|
archived: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
export type Candidate = z.infer<typeof candidateSchema>;
|
export type Candidate = z.infer<typeof candidateSchema>;
|
||||||
|
|
||||||
@ -203,14 +208,15 @@ export type CandidateRow = {
|
|||||||
averageScore: number | null;
|
averageScore: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pane 2 のグループ表示単位(ステージごとに候補者行を束ねる)。
|
// Pane 2 のグループ表示単位(ステージ or アーカイブ済み)。
|
||||||
// 候補者データは `INITIAL_CANDIDATES` を SSoT とし、`candidateGroups` で
|
// 候補者データは `INITIAL_CANDIDATES` を SSoT とし、`candidateGroups` で
|
||||||
// 派生計算して CandidateListPane に props で渡す(Workspace 内で計算)。
|
// 派生計算して CandidateListPane に props で渡す(Workspace 内で計算)。
|
||||||
//
|
//
|
||||||
// stage: StageKey は「+ ボタン」から `addCandidate(stage)` を呼ぶときの
|
// `kind: "stage"` は通常の選考ステージグループ。`stage: StageKey` は
|
||||||
// 引数に使う(型安全に StageKey を渡すため)。日本語ラベルは label に分離。
|
// 「+ ボタン」から `addCandidate(stage)` を呼ぶときの引数に使う。
|
||||||
export type Group = {
|
// `kind: "archived"` は archived === true の候補者を集めた末尾の仮想グループで、
|
||||||
stage: StageKey;
|
// 「+ 追加」操作は持たず、復元のみ可能。並び順は `kind: "stage"` を STAGE_ORDER で
|
||||||
label: string;
|
// 並べた後、最後に `kind: "archived"` を 1 つだけ置く(要素があるときのみ表示)。
|
||||||
items: CandidateRow[];
|
export type Group =
|
||||||
};
|
| { kind: "stage"; stage: StageKey; label: string; items: CandidateRow[] }
|
||||||
|
| { kind: "archived"; label: string; items: CandidateRow[] };
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@ -735,6 +738,59 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.65.0",
|
"version": "1.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.65.0.tgz",
|
||||||
|
|||||||
@ -15,6 +15,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user