379 lines
11 KiB
TypeScript
379 lines
11 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 {
|
||
|
|
Plus,
|
||
|
|
Search,
|
||
|
|
Trash2,
|
||
|
|
Edit2,
|
||
|
|
CheckCircle2,
|
||
|
|
XCircle,
|
||
|
|
} 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;
|
||
|
|
menuId: number; // 메뉴 스코프
|
||
|
|
onValueCountChange?: (count: number) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||
|
|
tableName,
|
||
|
|
columnName,
|
||
|
|
columnLabel,
|
||
|
|
menuId,
|
||
|
|
onValueCountChange,
|
||
|
|
}) => {
|
||
|
|
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
|
||
|
|
);
|
||
|
|
|
||
|
|
// 카테고리 값 로드
|
||
|
|
useEffect(() => {
|
||
|
|
loadCategoryValues();
|
||
|
|
}, [tableName, columnName, menuId]);
|
||
|
|
|
||
|
|
// 검색 필터링
|
||
|
|
useEffect(() => {
|
||
|
|
if (searchQuery) {
|
||
|
|
const filtered = values.filter(
|
||
|
|
(v) =>
|
||
|
|
v.valueCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
|
|
v.valueLabel.toLowerCase().includes(searchQuery.toLowerCase())
|
||
|
|
);
|
||
|
|
setFilteredValues(filtered);
|
||
|
|
} else {
|
||
|
|
setFilteredValues(values);
|
||
|
|
}
|
||
|
|
}, [searchQuery, values]);
|
||
|
|
|
||
|
|
const loadCategoryValues = async () => {
|
||
|
|
setIsLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await getCategoryValues(tableName, columnName, menuId);
|
||
|
|
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 {
|
||
|
|
const response = await addCategoryValue({
|
||
|
|
...newValue,
|
||
|
|
tableName,
|
||
|
|
columnName,
|
||
|
|
menuId,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.success && response.data) {
|
||
|
|
await loadCategoryValues();
|
||
|
|
setIsAddDialogOpen(false);
|
||
|
|
toast({
|
||
|
|
title: "성공",
|
||
|
|
description: "카테고리 값이 추가되었습니다",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast({
|
||
|
|
title: "오류",
|
||
|
|
description: "카테고리 값 추가에 실패했습니다",
|
||
|
|
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: "카테고리 값이 삭제되었습니다",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} 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]
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
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>
|
||
|
|
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
새 값 추가
|
||
|
|
</Button>
|
||
|
|
</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) => (
|
||
|
|
<div
|
||
|
|
key={value.valueId}
|
||
|
|
className="flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent"
|
||
|
|
>
|
||
|
|
<Checkbox
|
||
|
|
checked={selectedValueIds.includes(value.valueId!)}
|
||
|
|
onCheckedChange={() => handleSelectValue(value.valueId!)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div className="flex-1">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge variant="outline" className="text-xs">
|
||
|
|
{value.valueCode}
|
||
|
|
</Badge>
|
||
|
|
<span className="text-sm font-medium">
|
||
|
|
{value.valueLabel}
|
||
|
|
</span>
|
||
|
|
{value.isDefault && (
|
||
|
|
<Badge variant="secondary" className="text-[10px]">
|
||
|
|
기본값
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{value.color && (
|
||
|
|
<div
|
||
|
|
className="h-4 w-4 rounded-full border"
|
||
|
|
style={{ backgroundColor: value.color }}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{value.description && (
|
||
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
||
|
|
{value.description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{value.isActive ? (
|
||
|
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||
|
|
) : (
|
||
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
||
|
|
)}
|
||
|
|
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|