ERP-node/frontend/components/table-category/CategoryValueManager.tsx

484 lines
15 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
Trash2,
Edit2,
} from "lucide-react";
import {
getCategoryValues,
addCategoryValue,
updateCategoryValue,
deleteCategoryValue,
bulkDeleteCategoryValues,
} from "@/lib/api/tableCategoryValue";
import { TableCategoryValue } from "@/types/tableCategoryValue";
import { useToast } from "@/hooks/use-toast";
import { CategoryValueEditDialog } from "./CategoryValueEditDialog";
import { CategoryValueAddDialog } from "./CategoryValueAddDialog";
interface CategoryValueManagerProps {
tableName: string;
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
}
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
tableName,
columnName,
columnLabel,
onValueCountChange,
menuObjid,
}) => {
const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]);
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>(
[]
);
const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
null
);
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
// 카테고리 값 로드
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName]);
// 검색 필터링 + 비활성 필터링
useEffect(() => {
let filtered = values;
// 비활성 항목 필터링 (기본: 활성만 표시, 체크하면 비활성도 표시)
if (!showInactive) {
filtered = filtered.filter((v) => v.isActive !== false);
}
// 검색어 필터링
if (searchQuery) {
filtered = filtered.filter(
(v) =>
v.valueCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.valueLabel.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredValues(filtered);
}, [searchQuery, values, showInactive]);
const loadCategoryValues = async () => {
setIsLoading(true);
try {
// includeInactive: true로 비활성 값도 포함
const response = await getCategoryValues(tableName, columnName, true, menuObjid);
if (response.success && response.data) {
setValues(response.data);
setFilteredValues(response.data);
onValueCountChange?.(response.data.length);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
toast({
title: "오류",
description: "카테고리 값을 불러올 수 없습니다",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleAddValue = async (newValue: TableCategoryValue) => {
try {
if (!menuObjid) {
toast({
title: "오류",
description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.",
variant: "destructive",
});
return;
}
const response = await addCategoryValue(
{
...newValue,
tableName,
columnName,
},
menuObjid
);
if (response.success && response.data) {
await loadCategoryValues();
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어
toast({
title: "성공",
description: "카테고리 값이 추가되었습니다",
});
} else {
console.error("❌ 카테고리 값 추가 실패:", response);
toast({
title: "오류",
description: response.error || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
}
} catch (error: any) {
console.error("❌ 카테고리 값 추가 예외:", error);
toast({
title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
}
};
const handleUpdateValue = async (
valueId: number,
updates: Partial<TableCategoryValue>
) => {
try {
const response = await updateCategoryValue(valueId, updates);
if (response.success) {
await loadCategoryValues();
setEditingValue(null);
toast({
title: "성공",
description: "카테고리 값이 수정되었습니다",
});
}
} catch (error) {
toast({
title: "오류",
description: "카테고리 값 수정에 실패했습니다",
variant: "destructive",
});
}
};
const handleDeleteValue = async (valueId: number) => {
if (!confirm("정말로 이 카테고리 값을 삭제하시겠습니까?")) {
return;
}
try {
const response = await deleteCategoryValue(valueId);
if (response.success) {
await loadCategoryValues();
toast({
title: "성공",
description: "카테고리 값이 삭제되었습니다",
});
} else {
// 백엔드에서 반환한 상세 에러 메시지 표시
toast({
title: "삭제 불가",
description: response.error || response.message || "카테고리 값 삭제에 실패했습니다",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "오류",
description: "카테고리 값 삭제 중 오류가 발생했습니다",
variant: "destructive",
});
}
};
const handleBulkDelete = async () => {
if (selectedValueIds.length === 0) {
toast({
title: "알림",
description: "삭제할 항목을 선택해주세요",
variant: "destructive",
});
return;
}
if (
!confirm(`선택한 ${selectedValueIds.length}개 항목을 삭제하시겠습니까?`)
) {
return;
}
try {
const response = await bulkDeleteCategoryValues(selectedValueIds);
if (response.success) {
setSelectedValueIds([]);
await loadCategoryValues();
toast({
title: "성공",
description: response.message,
});
}
} catch (error) {
toast({
title: "오류",
description: "일괄 삭제에 실패했습니다",
variant: "destructive",
});
}
};
const handleSelectAll = () => {
if (selectedValueIds.length === filteredValues.length) {
setSelectedValueIds([]);
} else {
setSelectedValueIds(filteredValues.map((v) => v.valueId!));
}
};
const handleSelectValue = (valueId: number) => {
setSelectedValueIds((prev) =>
prev.includes(valueId)
? prev.filter((id) => id !== valueId)
: [...prev, valueId]
);
};
const handleToggleActive = async (valueId: number, currentIsActive: boolean) => {
try {
const response = await updateCategoryValue(valueId, {
isActive: !currentIsActive,
});
if (response.success) {
await loadCategoryValues();
toast({
title: "성공",
description: `카테고리 값이 ${!currentIsActive ? "활성화" : "비활성화"}되었습니다`,
});
} else {
toast({
title: "오류",
description: response.error || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
} catch (error: any) {
console.error("❌ 활성 상태 변경 실패:", error);
toast({
title: "오류",
description: error.message || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{columnLabel}</h3>
<p className="text-xs text-muted-foreground">
{filteredValues.length}
</p>
</div>
<div className="flex items-center gap-3">
{/* 비활성 항목 표시 옵션 */}
<div className="flex items-center gap-2">
<Checkbox
id="show-inactive"
checked={showInactive}
onCheckedChange={(checked) => setShowInactive(checked as boolean)}
/>
<label
htmlFor="show-inactive"
className="text-sm text-muted-foreground cursor-pointer whitespace-nowrap"
>
</label>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 검색바 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="코드 또는 라벨 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* 값 목록 */}
<div className="flex-1 overflow-y-auto p-4">
{filteredValues.length === 0 ? (
<div className="flex h-full items-center justify-center text-center">
<p className="text-sm text-muted-foreground">
{searchQuery
? "검색 결과가 없습니다"
: "카테고리 값을 추가해주세요"}
</p>
</div>
) : (
<div className="space-y-2">
{filteredValues.map((value) => {
const isInactive = value.isActive === false;
return (
<div
key={value.valueId}
className={`flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent ${
isInactive ? "opacity-50" : ""
}`}
>
<Checkbox
checked={selectedValueIds.includes(value.valueId!)}
onCheckedChange={() => handleSelectValue(value.valueId!)}
/>
<div className="flex flex-1 items-center gap-2">
{/* 색상 표시 (배지 없음 옵션 지원) */}
{value.color && value.color !== "none" && (
<div
className="h-4 w-4 rounded-full border flex-shrink-0"
style={{ backgroundColor: value.color }}
/>
)}
{value.color === "none" && (
<span className="text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded">
</span>
)}
{/* 라벨 */}
<span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>
{value.valueLabel}
</span>
{/* 설명 */}
{value.description && (
<span className="text-xs text-muted-foreground">
- {value.description}
</span>
)}
{/* 기본값 배지 */}
{value.isDefault && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{/* 비활성 배지 */}
{isInactive && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={value.isActive !== false}
onCheckedChange={() =>
handleToggleActive(
value.valueId!,
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingValue(value)}
className="h-8 w-8"
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteValue(value.valueId!)}
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 푸터: 일괄 작업 */}
{selectedValueIds.length > 0 && (
<div className="border-t p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={
selectedValueIds.length === filteredValues.length &&
filteredValues.length > 0
}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm text-muted-foreground">
{selectedValueIds.length}
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleBulkDelete}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 추가 다이얼로그 */}
<CategoryValueAddDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onAdd={handleAddValue}
columnLabel={columnLabel}
/>
{/* 편집 다이얼로그 */}
{editingValue && (
<CategoryValueEditDialog
open={!!editingValue}
onOpenChange={(open) => !open && setEditingValue(null)}
value={editingValue}
onUpdate={handleUpdateValue}
columnLabel={columnLabel}
/>
)}
</div>
);
};