2025-09-02 18:25:44 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React from "react";
|
|
|
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
|
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-12-23 09:31:18 +09:00
|
|
|
import { Edit, Trash2, CornerDownRight, Plus, ChevronRight, ChevronDown } from "lucide-react";
|
2025-09-02 18:25:44 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { useUpdateCode } from "@/hooks/queries/useCodes";
|
|
|
|
|
import type { CodeInfo } from "@/types/commonCode";
|
|
|
|
|
|
|
|
|
|
interface SortableCodeItemProps {
|
|
|
|
|
code: CodeInfo;
|
2025-09-03 14:57:52 +09:00
|
|
|
categoryCode: string;
|
2025-09-02 18:25:44 +09:00
|
|
|
onEdit: () => void;
|
|
|
|
|
onDelete: () => void;
|
2025-12-23 09:31:18 +09:00
|
|
|
onAddChild: () => void; // 하위 코드 추가
|
2025-09-03 18:23:23 +09:00
|
|
|
isDragOverlay?: boolean;
|
2025-12-23 09:31:18 +09:00
|
|
|
maxDepth?: number; // 최대 깊이 (기본값 3)
|
|
|
|
|
hasChildren?: boolean; // 자식이 있는지 여부
|
|
|
|
|
childCount?: number; // 자식 개수
|
|
|
|
|
isExpanded?: boolean; // 펼쳐진 상태
|
|
|
|
|
onToggleExpand?: () => void; // 접기/펼치기 토글
|
2025-09-02 18:25:44 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-03 18:23:23 +09:00
|
|
|
export function SortableCodeItem({
|
|
|
|
|
code,
|
|
|
|
|
categoryCode,
|
|
|
|
|
onEdit,
|
|
|
|
|
onDelete,
|
2025-12-23 09:31:18 +09:00
|
|
|
onAddChild,
|
2025-09-03 18:23:23 +09:00
|
|
|
isDragOverlay = false,
|
2025-12-23 09:31:18 +09:00
|
|
|
maxDepth = 3,
|
|
|
|
|
hasChildren = false,
|
|
|
|
|
childCount = 0,
|
|
|
|
|
isExpanded = true,
|
|
|
|
|
onToggleExpand,
|
2025-09-03 18:23:23 +09:00
|
|
|
}: SortableCodeItemProps) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
2025-12-23 09:31:18 +09:00
|
|
|
id: code.codeValue || code.code_value || "",
|
2025-09-03 18:23:23 +09:00
|
|
|
disabled: isDragOverlay,
|
|
|
|
|
});
|
2025-09-02 18:25:44 +09:00
|
|
|
const updateCodeMutation = useUpdateCode();
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 활성/비활성 토글 핸들러
|
|
|
|
|
const handleToggleActive = async (checked: boolean) => {
|
|
|
|
|
try {
|
2025-09-30 14:28:40 +09:00
|
|
|
const codeValue = code.codeValue || code.code_value;
|
|
|
|
|
if (!codeValue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
await updateCodeMutation.mutateAsync({
|
|
|
|
|
categoryCode,
|
2025-09-30 14:28:40 +09:00
|
|
|
codeValue: codeValue,
|
2025-09-02 18:25:44 +09:00
|
|
|
data: {
|
2025-09-30 14:28:40 +09:00
|
|
|
codeName: code.codeName || code.code_name,
|
|
|
|
|
codeNameEng: code.codeNameEng || code.code_name_eng || "",
|
2025-09-02 18:25:44 +09:00
|
|
|
description: code.description || "",
|
2025-09-30 14:28:40 +09:00
|
|
|
sortOrder: code.sortOrder || code.sort_order,
|
2025-09-02 18:25:44 +09:00
|
|
|
isActive: checked ? "Y" : "N",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("코드 활성 상태 변경 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-23 09:31:18 +09:00
|
|
|
// 계층구조 깊이에 따른 들여쓰기
|
|
|
|
|
const depth = code.depth || 1;
|
|
|
|
|
const indentLevel = (depth - 1) * 28; // 28px per level
|
|
|
|
|
const hasParent = !!(code.parentCodeValue || code.parent_code_value);
|
|
|
|
|
|
2025-09-02 18:25:44 +09:00
|
|
|
return (
|
2025-12-23 09:31:18 +09:00
|
|
|
<div className="flex items-stretch">
|
|
|
|
|
{/* 계층구조 들여쓰기 영역 */}
|
|
|
|
|
{depth > 1 && (
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center justify-end pr-2"
|
|
|
|
|
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
|
|
|
|
|
>
|
|
|
|
|
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
|
|
|
|
|
</div>
|
2025-09-02 18:25:44 +09:00
|
|
|
)}
|
2025-12-23 09:31:18 +09:00
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
className={cn(
|
|
|
|
|
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
|
|
|
|
|
isDragging && "cursor-grabbing opacity-50",
|
|
|
|
|
depth === 1 && "border-l-primary border-l-4",
|
|
|
|
|
depth === 2 && "border-l-4 border-l-blue-400",
|
|
|
|
|
depth === 3 && "border-l-4 border-l-green-400",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
|
|
|
|
|
{hasChildren && onToggleExpand && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onToggleExpand();
|
|
|
|
|
}}
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
|
|
|
|
title={isExpanded ? "접기" : "펼치기"}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<h4 className="text-sm font-semibold">{code.codeName || code.code_name}</h4>
|
|
|
|
|
{/* 접힌 상태에서 자식 개수 표시 */}
|
|
|
|
|
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
|
|
|
|
|
{/* 깊이 표시 배지 */}
|
|
|
|
|
{depth === 1 && (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
|
|
|
|
|
>
|
|
|
|
|
대분류
|
|
|
|
|
</Badge>
|
2025-09-02 18:25:44 +09:00
|
|
|
)}
|
2025-12-23 09:31:18 +09:00
|
|
|
{depth === 2 && (
|
|
|
|
|
<Badge variant="outline" className="bg-blue-50 px-1.5 py-0 text-[10px] text-blue-600">
|
|
|
|
|
중분류
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{depth === 3 && (
|
|
|
|
|
<Badge variant="outline" className="bg-green-50 px-1.5 py-0 text-[10px] text-green-600">
|
|
|
|
|
소분류
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{depth > 3 && (
|
|
|
|
|
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
|
|
|
|
|
{depth}단계
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<Badge
|
|
|
|
|
variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
|
|
|
|
|
className={cn(
|
|
|
|
|
"cursor-pointer text-xs transition-colors",
|
|
|
|
|
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
|
|
|
|
)}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (!updateCodeMutation.isPending) {
|
|
|
|
|
const isActive = code.isActive === "Y" || code.is_active === "Y";
|
|
|
|
|
handleToggleActive(!isActive);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-xs">{code.codeValue || code.code_value}</p>
|
|
|
|
|
{/* 부모 코드 표시 */}
|
|
|
|
|
{hasParent && (
|
|
|
|
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
|
|
|
|
상위: {code.parentCodeValue || code.parent_code_value}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-1"
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
|
|
|
|
|
{depth < maxDepth && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onAddChild();
|
|
|
|
|
}}
|
|
|
|
|
title="하위 코드 추가"
|
|
|
|
|
className="text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2025-09-02 18:25:44 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
2025-12-23 09:31:18 +09:00
|
|
|
onEdit();
|
2025-09-02 18:25:44 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2025-12-23 09:31:18 +09:00
|
|
|
<Edit className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onDelete();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
2025-09-02 18:25:44 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|