"use client"; import { useState } from "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 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, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } 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; onArchiveCandidate: (id: string) => void; onRestoreCandidate: (id: string) => void; onMoveCandidate: (id: string, toStage: StageKey, toIndex: number) => void; }; export function CandidateListPane({ groups, selectedCandidateId, onSelectCandidate, onAddCandidate, onArchiveCandidate, onRestoreCandidate, onMoveCandidate, }: CandidateListPaneProps) { const [addDialogStage, setAddDialogStage] = useState<{ stage: StageKey; label: string; } | null>(null); const [archiveTarget, setArchiveTarget] = useState<{ id: string; name: string; } | null>(null); const [archivedOpen, setArchivedOpen] = useState(false); const [activeDragId, setActiveDragId] = useState(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 => g.kind === "stage", ); const archivedGroup = groups.find( (g): g is Extract => 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 (

フロントエンドエンジニア

setActiveDragId(null)} >
{stageGroups.map((group) => ( setAddDialogStage({ stage: group.stage, label: group.label, }) } onArchiveRequest={(id, name) => setArchiveTarget({ id, name }) } /> ))} {archivedGroup && ( )}
{activeDragRow && (
{activeDragRow.row.name[0] ?? "?"}

{activeDragRow.row.name}

)}
{addDialogStage && ( { if (!open) setAddDialogStage(null); }} title="候補者を追加" description={`「${addDialogStage.label}」ステージに候補者を追加します`} fieldLabel="氏名" fieldId="candidate-name" placeholder="例: 山田 太郎" onAdd={(name) => onAddCandidate(addDialogStage.stage, name)} /> )} { if (!open) setArchiveTarget(null); }} title="候補者をアーカイブしますか?" itemName={archiveTarget?.name ?? ""} description={`「${archiveTarget?.name ?? ""}」をアーカイブします。後で「アーカイブ済み」から復元できます。`} actionLabel="アーカイブ" onConfirm={() => { if (archiveTarget) { onArchiveCandidate(archiveTarget.id); setArchiveTarget(null); } }} />
); } 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 (

{label}

{items.length}
i.id)} strategy={verticalListSortingStrategy} >
    {items.length === 0 ? ( ) : ( items.map((cand) => ( onArchiveRequest(cand.id, cand.name)} > アーカイブ } /> )) )}
); } 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 ( } >

{label}

{items.length}
    {items.map((cand) => ( ))}
); } function ArchivedRowItem({ cand, selected, onSelect, onRestore, }: { cand: CandidateRow; selected: boolean; onSelect: (id: string) => void; onRestore: (id: string) => void; }) { return (
  • } /> onRestore(cand.id)}> 復元
  • ); } function ScoreBadge({ avg, selected, }: { avg: number | null; selected: boolean; }) { if (avg === null) { return ( ); } return ( {avg.toFixed(1)} ); }