현대적 라이브러리 도입 완료

This commit is contained in:
hyeonsu 2025-09-02 18:25:44 +09:00
parent 40b2328876
commit 8b495b9e80
22 changed files with 1275 additions and 1048 deletions

View File

@ -48,6 +48,7 @@ export interface CreateCategoryData {
categoryNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: string;
}
export interface CreateCodeData {
@ -56,6 +57,7 @@ export interface CreateCodeData {
codeNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: string;
}
export class CommonCodeService {
@ -176,6 +178,8 @@ export class CommonCodeService {
updatedBy: string
) {
try {
// 디버깅: 받은 데이터 로그
logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
const category = await prisma.code_category.update({
where: { category_code: categoryCode },
data: {
@ -183,6 +187,12 @@ export class CommonCodeService {
category_name_eng: data.categoryNameEng,
description: data.description,
sort_order: data.sortOrder,
is_active:
typeof data.isActive === "boolean"
? data.isActive
? "Y"
: "N"
: data.isActive, // boolean이면 "Y"/"N"으로 변환
updated_by: updatedBy,
updated_date: new Date(),
},
@ -256,6 +266,8 @@ export class CommonCodeService {
updatedBy: string
) {
try {
// 디버깅: 받은 데이터 로그
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
const code = await prisma.code_info.update({
where: {
code_category_code_value: {
@ -268,6 +280,12 @@ export class CommonCodeService {
code_name_eng: data.codeNameEng,
description: data.description,
sort_order: data.sortOrder,
is_active:
typeof data.isActive === "boolean"
? data.isActive
? "Y"
: "N"
: data.isActive, // boolean이면 "Y"/"N"으로 변환
updated_by: updatedBy,
updated_date: new Date(),
},

View File

@ -784,6 +784,122 @@ export class CommonCodeService {
**목표 기간**: 1일 → **실제 소요**: 1일
### ✅ Phase 4.7: 현대적 라이브러리 도입 (완료!)
- [x] React Query 도입으로 데이터 페칭 최적화
- [x] React Hook Form 도입으로 폼 관리 개선
- [x] Zod 도입으로 스키마 기반 유효성 검사
- [x] Query Key 기반 캐시 무효화로 CRUD 업데이트 자동화
- [x] 컴포넌트 모듈화 (CategoryItem, SortableCodeItem 분리)
- [x] 로컬 상태 제거 및 서버 상태 단일화
- [x] 린터 오류 0개 달성
**구현 완료 내역:**
1. **React Query (@tanstack/react-query)**
- [x] 자동 캐싱 및 백그라운드 리페칭
- [x] Query Key 기반 캐시 무효화 (`frontend/lib/queryKeys.ts`)
- [x] Optimistic Updates (드래그앤드롭 순서 변경)
- [x] 로딩/에러 상태 자동 관리
- [x] 네트워크 요청 최적화
- [x] 커스텀 훅 구현: `useCategories`, `useCodes`, `useCreateCode`, `useUpdateCode`, `useDeleteCode`, `useReorderCodes`
2. **React Hook Form**
- [x] 성능 최적화 (불필요한 리렌더링 방지)
- [x] 간단한 폼 API 적용 (`CodeFormModal`, `CodeCategoryFormModal`)
- [x] Zod와 완벽 연동
- [x] TypeScript 완벽 지원
- [x] 실시간 검증 및 에러 메시지 표시
3. **Zod 스키마 검증**
- [x] 스키마 기반 데이터 구조 정의 (`frontend/lib/schemas/commonCode.ts`)
- [x] TypeScript 타입 자동 생성
- [x] 런타임 검증 + 컴파일 타입 안전성
- [x] 자동화된 에러 메시지
- [x] 카테고리/코드 생성/수정 스키마 분리
**주요 성과:**
- 🚀 **현대적 아키텍처 도입**: React Query + React Hook Form + Zod 완벽 통합
- 📈 **성능 최적화**: 불필요한 리렌더링 제거, 효율적 캐싱, Optimistic Updates
- 🔒 **타입 안전성**: 완벽한 TypeScript 지원으로 런타임 오류 방지
- 🧩 **컴포넌트 모듈화**: CategoryItem, SortableCodeItem 분리로 재사용성 향상
- 🎯 **상태 관리 단순화**: 서버 상태와 클라이언트 상태 명확히 분리
- ✨ **사용자 경험**: 즉시 반영되는 CRUD, 부드러운 드래그앤드롭
**목표 기간**: 2일 → **실제 소요**: 1일
**구현 계획 (완료):**
1. **1단계: 의존성 설치 및 설정**
```bash
npm install @tanstack/react-query react-hook-form @hookform/resolvers zod
```
2. **2단계: React Query 설정**
- QueryClient 설정 및 Provider 추가
- Query Key factory 함수 생성
- 커스텀 훅 생성 (`useCategories`, `useCodes`, `useCreateCode` 등)
3. **3단계: Zod 스키마 정의**
```typescript
const categorySchema = z.object({
categoryCode: z.string().regex(/^[A-Z0-9_]+$/, "대문자, 숫자, _만 가능"),
categoryName: z.string().min(1, "필수 입력").max(20, "20자 이하"),
categoryNameEng: z.string().max(20, "20자 이하"),
description: z.string().max(50, "50자 이하"),
sortOrder: z.number().min(1, "1 이상"),
});
const codeSchema = z.object({
codeValue: z.string().regex(/^[A-Z0-9_]+$/, "대문자, 숫자, _만 가능"),
codeName: z.string().min(1, "필수 입력").max(20, "20자 이하"),
codeNameEng: z.string().max(20, "20자 이하"),
description: z.string().max(50, "50자 이하"),
sortOrder: z.number().min(1, "1 이상"),
});
```
4. **4단계: Query Key 전략**
```typescript
// 카테고리 관련
["categories"][ // 모든 카테고리
("categories", { active: true })
][ // 활성 카테고리만
// 코드 관련
("codes", categoryCode)
][ // 특정 카테고리의 모든 코드
("codes", categoryCode, { active: true })
][("code", categoryCode, codeValue)]; // 특정 카테고리의 활성 코드만 // 특정 코드 상세
```
5. **5단계: React Hook Form 적용**
- `CodeFormModal`, `CodeCategoryFormModal` 리팩토링
- 기존 수동 검증 로직 제거
- Zod resolver 적용
6. **6단계: 기존 코드 정리**
- `useCommonCode` 훅 단순화
- 수동 상태 관리 코드 제거
- 수동 Optimistic Updates 로직 제거
**예상 개선 효과:**
- **코드량 40-50% 감소**: 보일러플레이트 코드 대폭 감소
- **타입 안전성 100% 보장**: 런타임 + 컴파일 타임 검증
- **성능 최적화**: 자동 캐싱, 불필요한 리렌더링 방지
- **개발자 경험 향상**: 자동화된 폼 검증, 에러 처리
- **유지보수성 향상**: 표준화된 패턴, 명확한 데이터 흐름
**목표 기간**: 2일
### ⏳ Phase 5: 화면관리 연계 (예정)
- [ ] column_labels와 연동 확인
@ -804,7 +920,7 @@ export class CommonCodeService {
## 🎯 현재 구현 상태
### 📊 **전체 진행률: 83%** 🎉
### 📊 **전체 진행률: 85%** 🎉
- ✅ **Phase 1**: 기본 구조 및 데이터베이스 (100%) - **완료!**
- ✅ **Phase 2**: 백엔드 API 구현 (100%) - **완료!**
@ -812,6 +928,7 @@ export class CommonCodeService {
- ✅ **Phase 4**: 고급 기능 구현 (100%) - **완료!**
- ✅ **Phase 4.5**: UX/UI 개선 (100%) - **완료!**
- ✅ **Phase 4.6**: CRUD 즉시 반영 개선 (100%) - **완료!**
- ✅ **Phase 4.7**: 현대적 라이브러리 도입 (100%) - **완료!**
- ⏳ **Phase 5**: 화면관리 연계 (0%)
- ⏳ **Phase 6**: 테스트 및 최적화 (0%)

View File

@ -2,7 +2,6 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
@ -26,7 +25,7 @@ export default function CommonCodeManagementPage() {
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
<div className="w-full lg:w-80 lg:flex-shrink-0">
<Card className="h-fit">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">📂 </CardTitle>
</CardHeader>

View File

@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider";
const inter = Inter({
subsets: ["latin"],
@ -39,7 +40,9 @@ export default function RootLayout({
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full">
{children}
<QueryProvider>
{children}
</QueryProvider>
</div>
</body>
</html>

View File

@ -0,0 +1,92 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCategory } from "@/hooks/queries/useCategories";
import type { CategoryInfo } from "@/types/commonCode";
interface CategoryItemProps {
category: CategoryInfo;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
const updateCategoryMutation = useUpdateCategory();
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
try {
await updateCategoryMutation.mutateAsync({
categoryCode: category.category_code,
data: {
categoryName: category.category_name,
categoryNameEng: category.category_name_eng || "",
description: category.description || "",
sortOrder: category.sort_order,
isActive: checked ? "Y" : "N",
},
});
} catch (error) {
console.error("카테고리 활성 상태 변경 실패:", error);
}
};
return (
<div
className={cn(
"group cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm",
isSelected ? "border-gray-300 bg-gray-100" : "border-gray-200 bg-white hover:bg-gray-50",
)}
onClick={onSelect}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{category.category_name}</h3>
<Badge
variant={category.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
category.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCategoryMutation.isPending) {
handleToggleActive(category.is_active !== "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{category.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{category.category_code}</p>
{category.description && <p className="mt-1 text-sm text-gray-500">{category.description}</p>}
</div>
{/* 액션 버튼 */}
{isSelected && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="ghost" onClick={onEdit}>
<Edit className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,248 +1,213 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
// import { useCommonCode } from "@/hooks/useCommonCode"; // 제거: 상태 공유 문제 해결
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeCategory, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/commonCode";
import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
import {
createCategorySchema,
updateCategorySchema,
type CreateCategoryData,
type UpdateCategoryData,
} from "@/lib/schemas/commonCode";
import type { CodeCategory } from "@/types/commonCode";
interface CodeCategoryFormModalProps {
isOpen: boolean;
onClose: () => void;
editingCategoryCode?: string;
categories: CodeCategory[];
onCreateCategory: (data: CreateCategoryRequest) => Promise<void>;
onUpdateCategory: (categoryCode: string, data: UpdateCategoryRequest) => Promise<void>;
}
export function CodeCategoryFormModal({
isOpen,
onClose,
editingCategoryCode,
categories,
onCreateCategory,
onUpdateCategory,
}: CodeCategoryFormModalProps) {
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
// const { categories, createCategory, updateCategory } = useCommonCode(); // 제거: props로 전달받음
export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }: CodeCategoryFormModalProps) {
const { data: categories = [] } = useCategories();
const createCategoryMutation = useCreateCategory();
const updateCategoryMutation = useUpdateCategory();
// 폼 상태
const [formData, setFormData] = useState({
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
isActive: true,
const isEditing = !!editingCategoryCode;
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
// 폼 스키마 선택 (생성/수정에 따라)
const schema = isEditing ? updateCategorySchema : createCategorySchema;
const form = useForm<CreateCategoryData | UpdateCategoryData>({
resolver: zodResolver(schema),
mode: "onChange", // 실시간 검증 활성화
defaultValues: {
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
...(isEditing && { isActive: true }),
},
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// 모달 열릴 때 데이터 초기화
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (editingCategoryCode && categories.length > 0) {
if (isEditing && editingCategory) {
// 수정 모드: 기존 데이터 로드
const category = categories.find((c) => c.category_code === editingCategoryCode);
if (category) {
console.log("🔄 카테고리 수정 모드 - 기존 데이터 로드:", category);
setFormData({
categoryCode: category.category_code,
categoryName: category.category_name,
categoryNameEng: category.category_name_eng || "",
description: category.description || "",
sortOrder: category.sort_order,
isActive: category.is_active === "Y",
});
}
form.reset({
categoryName: editingCategory.category_name,
categoryNameEng: editingCategory.category_name_eng || "",
description: editingCategory.description || "",
sortOrder: editingCategory.sort_order,
isActive: editingCategory.is_active === "Y",
});
} else {
// 새 카테고리 모드: 초기값 설정 및 자동 순서 계산
// 새 카테고리 모드: 자동 순서 계산
const maxSortOrder = categories.length > 0 ? Math.max(...categories.map((c) => c.sort_order)) : 0;
console.log("✨ 새 카테고리 모드 - 초기값 설정, 다음 순서:", maxSortOrder + 1);
setFormData({
form.reset({
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
isActive: true,
});
}
setErrors({});
}
}, [isOpen, editingCategoryCode, categories]);
// 입력값 검증
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.categoryCode.trim()) {
newErrors.categoryCode = "필수 입력 항목입니다.";
}
if (!formData.categoryName.trim()) {
newErrors.categoryName = "필수 입력 항목입니다.";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 폼 제출 핸들러
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setLoading(true);
}, [isOpen, isEditing, editingCategory, categories, form]);
const handleSubmit = form.handleSubmit(async (data) => {
try {
if (editingCategoryCode) {
if (isEditing && editingCategoryCode) {
// 수정
await onUpdateCategory(editingCategoryCode, {
categoryName: formData.categoryName,
categoryNameEng: formData.categoryNameEng,
description: formData.description,
sortOrder: formData.sortOrder,
isActive: formData.isActive,
await updateCategoryMutation.mutateAsync({
categoryCode: editingCategoryCode,
data: data as UpdateCategoryData,
});
} else {
// 생성
await onCreateCategory({
categoryCode: formData.categoryCode,
categoryName: formData.categoryName,
categoryNameEng: formData.categoryNameEng,
description: formData.description,
sortOrder: formData.sortOrder,
});
await createCategoryMutation.mutateAsync(data as CreateCategoryData);
}
onClose();
form.reset();
} catch (error) {
console.error("카테고리 저장 오류:", error);
// 에러 처리는 useCommonCode 훅에서 처리됨
} finally {
setLoading(false);
console.error("카테고리 저장 실패:", error);
}
};
});
// 입력값 변경 핸들러
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// 에러 제거
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingCategoryCode ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
<DialogTitle>{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 카테고리 코드 */}
<div className="space-y-2">
<Label htmlFor="categoryCode">{"카테고리 코드"} *</Label>
<Input
id="categoryCode"
value={formData.categoryCode}
onChange={(e) => handleChange("categoryCode", e.target.value)}
disabled={!!editingCategoryCode || loading}
placeholder={"카테고리 코드를 입력하세요"}
className={errors.categoryCode ? "border-red-500" : ""}
/>
{errors.categoryCode && <p className="text-sm text-red-600">{errors.categoryCode}</p>}
</div>
{/* 카테고리 코드 (생성 시에만) */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="categoryCode"> *</Label>
<Input
id="categoryCode"
{...form.register("categoryCode")}
disabled={isLoading}
placeholder="카테고리 코드를 입력하세요"
className={form.formState.errors.categoryCode ? "border-red-500" : ""}
/>
{form.formState.errors.categoryCode && (
<p className="text-sm text-red-600">{form.formState.errors.categoryCode.message}</p>
)}
</div>
)}
{/* 카테고리명 */}
<div className="space-y-2">
<Label htmlFor="categoryName">{"카테고리명"} *</Label>
<Label htmlFor="categoryName"> *</Label>
<Input
id="categoryName"
value={formData.categoryName}
onChange={(e) => handleChange("categoryName", e.target.value)}
disabled={loading}
placeholder={"카테고리명을 입력하세요"}
className={errors.categoryName ? "border-red-500" : ""}
{...form.register("categoryName")}
disabled={isLoading}
placeholder="카테고리명을 입력하세요"
className={form.formState.errors.categoryName ? "border-red-500" : ""}
/>
{errors.categoryName && <p className="text-sm text-red-600">{errors.categoryName}</p>}
{form.formState.errors.categoryName && (
<p className="text-sm text-red-600">{form.formState.errors.categoryName.message}</p>
)}
</div>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="categoryNameEng">{"카테고리 영문명"}</Label>
<Label htmlFor="categoryNameEng"> </Label>
<Input
id="categoryNameEng"
value={formData.categoryNameEng}
onChange={(e) => handleChange("categoryNameEng", e.target.value)}
disabled={loading}
placeholder={"카테고리 영문명을 입력하세요"}
{...form.register("categoryNameEng")}
disabled={isLoading}
placeholder="카테고리 영문명을 입력하세요"
className={form.formState.errors.categoryNameEng ? "border-red-500" : ""}
/>
{form.formState.errors.categoryNameEng && (
<p className="text-sm text-red-600">{form.formState.errors.categoryNameEng.message}</p>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description">{"설명"}</Label>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
disabled={loading}
placeholder={"설명을 입력하세요"}
{...form.register("description")}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={form.formState.errors.description ? "border-red-500" : ""}
/>
{form.formState.errors.description && (
<p className="text-sm text-red-600">{form.formState.errors.description.message}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder">{"정렬 순서"}</Label>
<Label htmlFor="sortOrder"> </Label>
<Input
id="sortOrder"
type="number"
value={formData.sortOrder}
onChange={(e) => handleChange("sortOrder", parseInt(e.target.value) || 0)}
disabled={loading}
min={0}
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
/>
{form.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{form.formState.errors.sortOrder.message}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{editingCategoryCode && (
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => handleChange("isActive", checked)}
disabled={loading}
/>
<Label htmlFor="isActive">{"활성"}</Label>
<Switch id="isActive" {...form.register("isActive")} disabled={isLoading} />
<Label htmlFor="isActive"></Label>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
{"취소"}
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? (
<Button type="submit" disabled={isLoading || !form.formState.isValid}>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{"저장 중..."}
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"카테고리 수정"
) : (
"저장"
"카테고리 저장"
)}
</Button>
</div>

View File

@ -1,16 +1,15 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useCommonCode } from "@/hooks/useCommonCode";
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
import { CategoryItem } from "./CategoryItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus, Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Search, Plus } from "lucide-react";
import { useCategories, useDeleteCategory } from "@/hooks/queries/useCategories";
interface CodeCategoryPanelProps {
selectedCategoryCode: string;
@ -18,88 +17,87 @@ interface CodeCategoryPanelProps {
}
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
// useMultiLang 호출 제거 - 상위에서 전달받도록 수정
const {
categories,
categoriesLoading,
categoriesError,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
} = useCommonCode();
// React Query로 카테고리 데이터 관리
const { data: categories = [], isLoading, error } = useCategories();
const deleteCategoryMutation = useDeleteCategory();
// 로컬 상태
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false); // 활성 필터 상태
const [showActiveOnly, setShowActiveOnly] = useState(false);
const [showFormModal, setShowFormModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<string>("");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCategory, setDeletingCategory] = useState<string>("");
// 검색 및 활성 상태 필터링
// 필터링된 카테고리 목록
const filteredCategories = categories.filter((category) => {
// 검색 조건
const matchesSearch =
category.category_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
category.category_code.toLowerCase().includes(searchTerm.toLowerCase());
// 활성 상태 필터 조건
const matchesActiveFilter = showActiveOnly ? category.is_active : true;
const matchesActive = !showActiveOnly || category.is_active === "Y";
return matchesSearch && matchesActiveFilter;
return matchesSearch && matchesActive;
});
// 카테고리 생성 핸들러
const handleCreateCategory = () => {
// 카테고리 생성
const handleNewCategory = () => {
setEditingCategory("");
setShowFormModal(true);
};
// 카테고리 수정 핸들러
// 카테고리 수정
const handleEditCategory = (categoryCode: string) => {
setEditingCategory(categoryCode);
setShowFormModal(true);
};
// 카테고리 삭제 핸들러
// 카테고리 삭제 확인
const handleDeleteCategory = (categoryCode: string) => {
setDeletingCategory(categoryCode);
setShowDeleteModal(true);
};
// 삭제 확인 핸들러
// 카테고리 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCategory) return;
try {
await deleteCategory(deletingCategory);
await deleteCategoryMutation.mutateAsync(deletingCategory);
// 삭제된 카테고리가 선택된 상태라면 선택 해제
if (selectedCategoryCode === deletingCategory) {
onSelectCategory("");
}
setShowDeleteModal(false);
setDeletingCategory("");
} catch (error) {
console.error("카테고리 삭제 오류:", error);
// 에러 처리는 useCommonCode 훅에서 처리됨
console.error("카테고리 삭제 실패:", error);
}
};
if (categoriesError) {
if (error) {
return (
<div className="p-4 text-center text-red-600">
<p className="mb-2"> {categoriesError}</p>
<Button onClick={() => fetchCategories()} variant="outline" size="sm">
</Button>
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 추가 버튼 */}
<div className="space-y-3 border-b p-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="카테고리 검색..."
value={searchTerm}
@ -107,102 +105,49 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
className="pl-10"
/>
</div>
</div>
{/* 활성 상태 필터 토글 */}
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-2 text-sm">
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500"
className="rounded border-gray-300"
/>
</label>
</div>
<label htmlFor="activeOnly" className="text-sm text-gray-600">
</label>
</div>
<Button onClick={handleCreateCategory} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{/* 새 카테고리 버튼 */}
<Button onClick={handleNewCategory} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 카테고리 목록 */}
<div className="max-h-96 overflow-y-auto">
{categoriesLoading ? (
<div className="p-4 text-center">
<LoadingSpinner size="sm" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : filteredCategories.length === 0 ? (
<div className="text-muted-foreground p-4 text-center">
<p> .</p>
<div className="p-4 text-center text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
</div>
) : (
<div className="space-y-1">
<div className="space-y-1 p-2">
{filteredCategories.map((category) => (
<div
<CategoryItem
key={category.category_code}
className={cn(
"group flex cursor-pointer items-center justify-between rounded-lg p-3 transition-colors",
selectedCategoryCode === category.category_code
? "border-2 border-gray-300 bg-gray-100 shadow-sm"
: "hover:bg-muted",
)}
onClick={() => onSelectCategory(category.category_code)}
>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-sm font-medium">{category.category_name}</span>
{category.is_active === "Y" ? (
<Badge variant="secondary" className="text-xs">
</Badge>
) : (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
<p className="text-muted-foreground truncate text-xs">{category.category_code}</p>
{category.description && (
<p className="text-muted-foreground mt-1 truncate text-xs">{category.description}</p>
)}
</div>
{/* 액션 버튼 */}
<div
className={cn(
"flex gap-1 transition-opacity",
selectedCategoryCode === category.category_code
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditCategory(category.category_code);
}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
handleDeleteCategory(category.category_code);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
category={category}
isSelected={selectedCategoryCode === category.category_code}
onSelect={() => onSelectCategory(category.category_code)}
onEdit={() => handleEditCategory(category.category_code)}
onDelete={() => handleDeleteCategory(category.category_code)}
/>
))}
</div>
)}
@ -214,9 +159,6 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
editingCategoryCode={editingCategory}
categories={categories}
onCreateCategory={createCategory}
onUpdateCategory={updateCategory}
/>
)}
@ -225,11 +167,11 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
type="error"
title="삭제 확인"
message="이 카테고리를 삭제하시겠습니까? 관련된 모든 코드도 함께 삭제됩니다."
title="카테고리 삭제"
message="정말로 이 카테고리를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
)}
</div>

View File

@ -1,16 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useCommonCode } from "@/hooks/useCommonCode";
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
import { CodeFormModal } from "./CodeFormModal";
import { SortableCodeItem } from "./SortableCodeItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus, Edit, Trash2, GripVertical } from "lucide-react";
import { Search, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCodes, useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
import type { CodeInfo } from "@/types/commonCode";
// Drag and Drop
import {
DndContext,
closestCenter,
@ -18,7 +21,9 @@ import {
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
@ -26,93 +31,30 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface CodeDetailPanelProps {
categoryCode: string;
}
// 드래그 가능한 코드 아이템 컴포넌트
interface SortableCodeItemProps {
code: any;
onEdit: (code: any) => void;
onDelete: (code: any) => void;
}
function SortableCodeItem({ code, onEdit, onDelete }: SortableCodeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `${code.code_category}-${code.code_value}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group flex items-center justify-between rounded-lg p-3 transition-colors",
isDragging ? "border-2 border-blue-200 bg-blue-50 opacity-50 shadow-lg" : "hover:bg-muted",
)}
{...attributes}
>
{/* 드래그 핸들 */}
<div className="mr-2 cursor-grab text-gray-400 hover:text-gray-600 active:cursor-grabbing" {...listeners}>
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-sm font-medium">{code.code_name}</span>
{code.is_active === "Y" ? (
<Badge variant="secondary" className="text-xs">
</Badge>
) : (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span className="font-mono">{code.code_value}</span>
{code.code_name_eng && <span>({code.code_name_eng})</span>}
</div>
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(code)}
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(code)}
className="text-red-600 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
const { codes, setCodes, codesLoading, codesError, fetchCodes, createCode, updateCode, deleteCode, reorderCodes } =
useCommonCode();
// React Query로 코드 데이터 관리
const { data: codes = [], isLoading, error } = useCodes(categoryCode);
const deleteCodeMutation = useDeleteCode();
const reorderCodesMutation = useReorderCodes();
// 드래그앤드롭 센서 설정
// 로컬 상태
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
const [showFormModal, setShowFormModal] = useState(false);
const [editingCode, setEditingCode] = useState<CodeInfo | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
// 드래그 상태
const [activeId, setActiveId] = useState<string | null>(null);
// Drag and Drop 센서
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
@ -120,154 +62,116 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
}),
);
// 카테고리 변경 시 코드 조회
useEffect(() => {
if (categoryCode) {
console.log("🔍 카테고리 변경됨, 코드 조회:", categoryCode);
fetchCodes(categoryCode);
}
}, [categoryCode, fetchCodes]);
// 로컬 상태
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false); // 활성 필터 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingCode, setEditingCode] = useState<any>(null); // 전체 코드 객체 저장
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCode, setDeletingCode] = useState<{ categoryCode: string; codeValue: string }>({
categoryCode: "",
codeValue: "",
});
// 검색 및 활성 상태 필터링
// 필터링된 코드 목록
const filteredCodes = codes.filter((code) => {
// 검색 조건
const matchesSearch =
code.code_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
code.code_value.toLowerCase().includes(searchTerm.toLowerCase());
// 활성 상태 필터 조건
const matchesActiveFilter = showActiveOnly ? code.is_active : true;
const matchesActive = !showActiveOnly || code.is_active === "Y";
return matchesSearch && matchesActiveFilter;
return matchesSearch && matchesActive;
});
// 코드 생성 핸들러
const handleCreateCode = () => {
if (!categoryCode) return;
setEditingCode(null); // 새 코드 모드
// 새 코드 생성
const handleNewCode = () => {
setEditingCode(null);
setShowFormModal(true);
};
// 코드 수정 핸들러
const handleEditCode = (codeValue: string) => {
console.log("🔧 코드 수정 핸들러 호출:", { categoryCode, codeValue });
const codeToEdit = codes.find((code) => code.code_value === codeValue);
console.log("📋 수정할 코드 데이터:", codeToEdit);
setEditingCode(codeToEdit || null); // 전체 코드 객체 전달
// 코드 수정
const handleEditCode = (code: CodeInfo) => {
setEditingCode(code);
setShowFormModal(true);
};
// 코드 삭제 핸들러
const handleDeleteCode = (codeValue: string) => {
setDeletingCode({ categoryCode, codeValue });
// 코드 삭제 확인
const handleDeleteCode = (code: CodeInfo) => {
setDeletingCode(code);
setShowDeleteModal(true);
};
// 삭제 확인 핸들러
// 코드 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCode.categoryCode || !deletingCode.codeValue) return;
if (!deletingCode) return;
try {
await deleteCode(deletingCode.categoryCode, deletingCode.codeValue);
await deleteCodeMutation.mutateAsync({
categoryCode,
codeValue: deletingCode.code_value,
});
setShowDeleteModal(false);
setDeletingCode({ categoryCode: "", codeValue: "" });
setDeletingCode(null);
} catch (error) {
console.error("코드 삭제 오류:", error);
// 에러 처리는 useCommonCode 훅에서 처리됨
console.error("코드 삭제 실패:", error);
}
};
// 드래그 종료 핸들러
// 드래그 시작 핸들러
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
// 드래그 앤 드롭 처리
const handleDragEnd = async (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
if (over && active.id !== over.id) {
const oldIndex = filteredCodes.findIndex((code) => code.code_value === active.id);
const newIndex = filteredCodes.findIndex((code) => code.code_value === over.id);
const activeIndex = filteredCodes.findIndex((code) => `${code.code_category}-${code.code_value}` === active.id);
const overIndex = filteredCodes.findIndex((code) => `${code.code_category}-${code.code_value}` === over.id);
const newOrder = arrayMove(filteredCodes, oldIndex, newIndex);
if (activeIndex !== overIndex) {
// 전체 codes 배열에서 현재 카테고리의 코드들을 찾아서 재정렬
const currentCategoryCodes = codes.filter((code) => code.code_category === categoryCode);
const otherCategoryCodes = codes.filter((code) => code.code_category !== categoryCode);
// 현재 카테고리 코드들의 순서를 변경
const reorderedCategoryCodes = arrayMove(currentCategoryCodes, activeIndex, overIndex);
// 전체 codes 배열 업데이트
const newCodesArray = [...otherCategoryCodes, ...reorderedCategoryCodes];
setCodes(newCodesArray);
// 순서 업데이트를 위한 데이터 준비
const reorderData = newOrder.map((code, index) => ({
codeValue: code.code_value,
sortOrder: index + 1,
}));
try {
// 서버에 순서 변경 요청
console.log("🔄 코드 순서 변경:", {
await reorderCodesMutation.mutateAsync({
categoryCode,
from: activeIndex,
to: overIndex,
reorderedCodes: reorderedCategoryCodes.map((code) => code.code_value),
codes: reorderData,
});
// 백엔드 API 호출 - 실제 DB에 순서 저장
await reorderCodes(
categoryCode,
reorderedCategoryCodes.map((code, index) => ({
codeValue: code.code_value,
sortOrder: index + 1,
})),
);
} catch (error) {
console.error("순서 변경 실패:", error);
// 실패 시 원래 순서로 복원
fetchCodes(categoryCode);
console.error("코드 순서 변경 실패:", error);
}
}
};
// 카테고리가 선택되지 않은 경우
if (!categoryCode) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
<div className="text-center">
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-sm"> .</p>
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<p> </p>
</div>
</div>
);
}
// 에러 상태
if (codesError) {
if (error) {
return (
<div className="p-4 text-center text-red-600">
<p className="mb-2"> {codesError}</p>
<Button onClick={() => fetchCodes(categoryCode)} variant="outline" size="sm">
</Button>
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 추가 버튼 */}
<div className="space-y-3 border-b p-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="코드 검색..."
value={searchTerm}
@ -275,55 +179,99 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
className="pl-10"
/>
</div>
</div>
{/* 활성 상태 필터 토글 */}
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-2 text-sm">
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnlyCodes"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500"
className="rounded border-gray-300"
/>
</label>
</div>
<label htmlFor="activeOnlyCodes" className="text-sm text-gray-600">
</label>
</div>
<Button onClick={handleCreateCode} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{/* 새 코드 버튼 */}
<Button onClick={handleNewCode} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 코드 목록 (드래그앤드롭) */}
<div className="max-h-96 overflow-y-auto">
{codesLoading ? (
<div className="p-4 text-center">
<LoadingSpinner size="sm" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
{/* 코드 목록 */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : filteredCodes.length === 0 ? (
<div className="text-muted-foreground p-4 text-center">
<p> .</p>
<div className="p-4 text-center text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "코드가 없습니다."}
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={filteredCodes.map((code) => `${code.code_category}-${code.code_value}`)}
strategy={verticalListSortingStrategy}
<div className="p-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-1">
{filteredCodes.map((code) => (
<SortableCodeItem
key={`${code.code_category}-${code.code_value}`}
code={code}
onEdit={(code) => handleEditCode(code.code_value)}
onDelete={(code) => handleDeleteCode(code.code_value)}
/>
))}
</div>
</SortableContext>
</DndContext>
<SortableContext
items={filteredCodes.map((code) => code.code_value)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{filteredCodes.map((code) => (
<SortableCodeItem
key={code.code_value}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeId ? (
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
{(() => {
const activeCode = filteredCodes.find((code) => code.code_value === activeId);
if (!activeCode) return null;
return (
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3>
<Badge
variant={activeCode.is_active === "Y" ? "default" : "secondary"}
className={cn(
"transition-colors",
activeCode.is_active === "Y"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600",
)}
>
{activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p>
{activeCode.description && (
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
)}
</div>
</div>
);
})()}
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
)}
</div>
@ -333,13 +281,10 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
isOpen={showFormModal}
onClose={() => {
setShowFormModal(false);
setEditingCode(null); // 모달 닫을 때 편집 상태 초기화
setEditingCode(null);
}}
categoryCode={categoryCode}
editingCode={editingCode} // 전체 코드 객체 전달
codes={codes} // 현재 코드 목록 전달
onCreateCode={createCode} // 코드 생성 함수 전달
onUpdateCode={updateCode} // 코드 수정 함수 전달
editingCode={editingCode}
/>
)}
@ -348,11 +293,11 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
type="error"
title="삭제 확인"
message="이 코드를 삭제하시겠습니까?"
title="코드 삭제"
message="정말로 이 코드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
)}
</div>

View File

@ -1,322 +1,227 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
// import { useCommonCode } from "@/hooks/useCommonCode"; // 제거: 상태 공유 문제 해결
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeInfo, CreateCodeRequest, UpdateCodeRequest } from "@/types/commonCode";
import { useCodes, useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
import type { CodeInfo } from "@/types/commonCode";
import type { FieldError } from "react-hook-form";
interface CodeFormModalProps {
isOpen: boolean;
onClose: () => void;
categoryCode: string;
editingCode?: CodeInfo | null; // 수정할 코드 객체 (null이면 새 코드)
codes: CodeInfo[];
onCreateCode: (categoryCode: string, data: CreateCodeRequest) => Promise<void>;
onUpdateCode: (categoryCode: string, codeValue: string, data: UpdateCodeRequest) => Promise<void>;
editingCode?: CodeInfo | null;
}
export function CodeFormModal({
isOpen,
onClose,
categoryCode,
editingCode,
codes,
onCreateCode,
onUpdateCode,
}: CodeFormModalProps) {
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
// const { codes, createCode, updateCode } = useCommonCode(); // 제거: props로 전달받음
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
const getErrorMessage = (error: FieldError | undefined): string => {
if (!error) return "";
if (typeof error === "string") return error;
return error.message || "";
};
// 폼 상태
const [formData, setFormData] = useState({
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: 1,
isActive: true,
export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: CodeFormModalProps) {
const { data: codes = [] } = useCodes(categoryCode);
const createCodeMutation = useCreateCode();
const updateCodeMutation = useUpdateCode();
const isEditing = !!editingCode;
// 폼 스키마 선택 (생성/수정에 따라)
const schema = isEditing ? updateCodeSchema : createCodeSchema;
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange", // 실시간 검증 활성화
defaultValues: {
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: 1,
...(isEditing && { isActive: "Y" as const }),
},
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// 모달 열릴 때 데이터 초기화
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
console.log("🚀 CodeFormModal useEffect 실행:", {
isOpen,
editingCode,
categoryCode,
});
if (isOpen) {
if (editingCode) {
// 수정 모드: 전달받은 코드 데이터 사용
console.log("🔄 수정 모드 - 기존 데이터 로드:", editingCode);
setFormData({
codeValue: editingCode.code_value,
if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
form.reset({
codeName: editingCode.code_name,
codeNameEng: editingCode.code_name_eng || "",
description: editingCode.description || "",
sortOrder: editingCode.sort_order,
isActive: editingCode.is_active === "Y",
isActive: editingCode.is_active as "Y" | "N", // 타입 캐스팅
});
// codeValue는 별도로 설정 (표시용)
form.setValue("codeValue" as any, editingCode.code_value);
} else {
// 새 코드 모드: 초기값 설정
// 새 코드 모드: 자동 순서 계산
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order)) : 0;
console.log("✨ 새 코드 모드 - 초기값 설정, 다음 순서:", maxSortOrder + 1);
setFormData({
form.reset({
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
isActive: true,
});
}
setErrors({});
}
}, [isOpen, editingCode, codes, categoryCode]);
// 실시간 필드 검증
const validateField = (fieldName: string, value: string) => {
const newErrors = { ...errors };
switch (fieldName) {
case "codeValue":
if (!value.trim()) {
newErrors.codeValue = "필수 입력 항목입니다.";
} else if (value.length > 50) {
newErrors.codeValue = "코드값은 50자 이하로 입력해주세요.";
} else if (!/^[A-Z0-9_]+$/.test(value)) {
newErrors.codeValue = "대문자, 숫자, 언더스코어(_)만 사용 가능합니다.";
} else {
delete newErrors.codeValue;
}
break;
case "codeName":
if (!value.trim()) {
newErrors.codeName = "필수 입력 항목입니다.";
} else if (value.length > 100) {
newErrors.codeName = "코드명은 100자 이하로 입력해주세요.";
} else {
delete newErrors.codeName;
}
break;
case "codeNameEng":
if (value && value.length > 100) {
newErrors.codeNameEng = "영문명은 100자 이하로 입력해주세요.";
} else {
delete newErrors.codeNameEng;
}
break;
case "description":
if (value && value.length > 500) {
newErrors.description = "설명은 500자 이하로 입력해주세요.";
} else {
delete newErrors.description;
}
break;
}
setErrors(newErrors);
};
// 전체 폼 검증
const validateForm = () => {
const newErrors: Record<string, string> = {};
// 필수 필드 검증
if (!formData.codeValue.trim()) {
newErrors.codeValue = "필수 입력 항목입니다.";
} else if (!/^[A-Z0-9_]+$/.test(formData.codeValue)) {
newErrors.codeValue = "대문자, 숫자, 언더스코어(_)만 사용 가능합니다.";
}
if (!formData.codeName.trim()) {
newErrors.codeName = "필수 입력 항목입니다.";
}
// 중복 검사 (신규 생성 시)
if (!editingCode) {
const existingCode = codes.find((c) => c.code_value === formData.codeValue);
if (existingCode) {
newErrors.codeValue = "이미 존재하는 코드값입니다.";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 폼 제출 핸들러
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setLoading(true);
}, [isOpen, isEditing, editingCode, codes, form]);
const handleSubmit = form.handleSubmit(async (data) => {
try {
if (editingCode) {
if (isEditing && editingCode) {
// 수정
await onUpdateCode(categoryCode, editingCode.code_value, {
codeName: formData.codeName,
codeNameEng: formData.codeNameEng,
description: formData.description,
sortOrder: formData.sortOrder,
isActive: formData.isActive,
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: editingCode.code_value,
data: data as UpdateCodeData,
});
} else {
// 생성
await onCreateCode(categoryCode, {
codeValue: formData.codeValue,
codeName: formData.codeName,
codeNameEng: formData.codeNameEng,
description: formData.description,
sortOrder: formData.sortOrder,
await createCodeMutation.mutateAsync({
categoryCode,
data: data as CreateCodeData,
});
}
onClose();
form.reset();
} catch (error) {
console.error("코드 저장 오류:", error);
// 에러 처리는 useCommonCode 훅에서 처리됨
} finally {
setLoading(false);
console.error("코드 저장 실패:", error);
}
};
});
// 입력값 변경 핸들러 (실시간 검증 포함)
const handleChange = (field: string, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// 실시간 검증 (문자열 필드만)
if (typeof value === "string") {
validateField(field, value);
} else {
// 에러 제거 (숫자, 불린 필드)
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
}
};
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingCode ? "코드 수정" : "새 코드"}</DialogTitle>
<DialogTitle>{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 코드값 */}
<div className="space-y-2">
<Label htmlFor="codeValue">{"코드값"} *</Label>
<Label htmlFor="codeValue"> *</Label>
<Input
id="codeValue"
value={formData.codeValue}
onChange={(e) => handleChange("codeValue", e.target.value.toUpperCase())}
disabled={!!editingCode || loading}
placeholder={"코드값을 입력하세요 (예: USER_ACTIVE)"}
className={errors.codeValue ? "border-red-500" : ""}
{...form.register("codeValue")}
disabled={isLoading || isEditing} // 수정 시에는 비활성화
placeholder="코드값을 입력하세요"
className={(form.formState.errors as any)?.codeValue ? "border-red-500" : ""}
/>
{errors.codeValue && <p className="text-sm text-red-600">{errors.codeValue}</p>}
{(form.formState.errors as any)?.codeValue && (
<p className="text-sm text-red-600">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
)}
</div>
{/* 코드명 */}
<div className="space-y-2">
<Label htmlFor="codeName">{"코드명"} *</Label>
<Label htmlFor="codeName"> *</Label>
<Input
id="codeName"
value={formData.codeName}
onChange={(e) => handleChange("codeName", e.target.value)}
disabled={loading}
placeholder={"코드명을 입력하세요"}
className={errors.codeName ? "border-red-500" : ""}
{...form.register("codeName")}
disabled={isLoading}
placeholder="코드명을 입력하세요"
className={form.formState.errors.codeName ? "border-red-500" : ""}
/>
{errors.codeName && <p className="text-sm text-red-600">{errors.codeName}</p>}
{form.formState.errors.codeName && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeName)}</p>
)}
</div>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="codeNameEng">{"코드 영문명"}</Label>
<Label htmlFor="codeNameEng"> *</Label>
<Input
id="codeNameEng"
value={formData.codeNameEng}
onChange={(e) => handleChange("codeNameEng", e.target.value)}
disabled={loading}
placeholder={"코드 영문명을 입력하세요"}
className={errors.codeNameEng ? "border-red-500" : ""}
{...form.register("codeNameEng")}
disabled={isLoading}
placeholder="코드 영문명을 입력하세요"
className={form.formState.errors.codeNameEng ? "border-red-500" : ""}
/>
{errors.codeNameEng && <p className="text-sm text-red-600">{errors.codeNameEng}</p>}
{form.formState.errors.codeNameEng && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description">{"설명"}</Label>
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
disabled={loading}
placeholder={"설명을 입력하세요"}
{...form.register("description")}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={errors.description ? "border-red-500" : ""}
className={form.formState.errors.description ? "border-red-500" : ""}
/>
{errors.description && <p className="text-sm text-red-600">{errors.description}</p>}
{form.formState.errors.description && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.description)}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder">{"정렬 순서"}</Label>
<Label htmlFor="sortOrder"> </Label>
<Input
id="sortOrder"
type="number"
value={formData.sortOrder}
onChange={(e) => handleChange("sortOrder", parseInt(e.target.value) || 0)}
disabled={loading}
min={0}
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
/>
{form.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.sortOrder)}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{editingCode && (
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => handleChange("isActive", checked)}
disabled={loading}
checked={form.watch("isActive") === "Y"}
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
disabled={isLoading}
/>
<Label htmlFor="isActive">{"활성"}</Label>
<Label htmlFor="isActive">{form.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
{"취소"}
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
</Button>
<Button type="submit" disabled={loading || Object.keys(errors).length > 0}>
{loading ? (
<Button type="submit" disabled={isLoading || !form.formState.isValid}>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{editingCode ? "수정 중..." : "등록 중..."}
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : editingCode ? (
) : isEditing ? (
"코드 수정"
) : (
"코드 등록"
"코드 저장"
)}
</Button>
</div>

View File

@ -0,0 +1,122 @@
"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";
import { Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCode } from "@/hooks/queries/useCodes";
import type { CodeInfo } from "@/types/commonCode";
interface SortableCodeItemProps {
code: CodeInfo;
onEdit: () => void;
onDelete: () => void;
categoryCode: string;
}
export function SortableCodeItem({ code, onEdit, onDelete, categoryCode }: SortableCodeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: code.code_value });
const updateCodeMutation = useUpdateCode();
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
try {
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: code.code_value,
data: {
codeName: code.code_name,
codeNameEng: code.code_name_eng || "",
description: code.description || "",
sortOrder: code.sort_order,
isActive: checked ? "Y" : "N",
},
});
} catch (error) {
console.error("코드 활성 상태 변경 실패:", error);
}
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"group cursor-grab rounded-lg border p-3 transition-all hover:shadow-sm",
"border-gray-200 bg-white hover:bg-gray-50",
isDragging && "cursor-grabbing opacity-50",
)}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{code.code_name}</h3>
<Badge
variant={code.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
code.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCodeMutation.isPending) {
handleToggleActive(code.is_active !== "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{code.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{code.code_value}</p>
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
</div>
{/* 액션 버튼 */}
<div
className="flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit();
}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -6,7 +6,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
className={cn("bg-card text-card-foreground flex flex-col rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);

View File

@ -11,8 +11,8 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-sm transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-blue-500 data-[state=unchecked]:bg-gray-300",
"hover:data-[state=checked]:bg-blue-600 hover:data-[state=unchecked]:bg-gray-400",
"data-[state=checked]:bg-green-500 data-[state=unchecked]:bg-gray-300",
"hover:data-[state=checked]:bg-green-600 hover:data-[state=unchecked]:bg-gray-400",
"focus-visible:border-ring focus-visible:ring-ring/50",
className,
)}

View File

@ -0,0 +1,79 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import type { CategoryFilter, CreateCategoryData, UpdateCategoryData } from "@/lib/schemas/commonCode";
/**
*
*/
export function useCategories(filters?: CategoryFilter) {
return useQuery({
queryKey: queryKeys.categories.list(filters),
queryFn: () => commonCodeApi.categories.getList(filters),
select: (data) => data.data || [],
});
}
/**
*
*/
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategoryData) => commonCodeApi.categories.create(data),
onSuccess: () => {
// 모든 카테고리 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
},
onError: (error) => {
console.error("카테고리 생성 실패:", error);
},
});
}
/**
*
*/
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: UpdateCategoryData }) =>
commonCodeApi.categories.update(categoryCode, data),
onSuccess: (_, variables) => {
// 해당 카테고리 상세 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.categories.detail(variables.categoryCode)
});
// 모든 카테고리 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.categories.lists() });
},
onError: (error) => {
console.error("카테고리 수정 실패:", error);
},
});
}
/**
*
*/
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (categoryCode: string) => commonCodeApi.categories.delete(categoryCode),
onSuccess: (_, categoryCode) => {
// 해당 카테고리와 관련된 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
queryClient.invalidateQueries({ queryKey: queryKeys.codes.all });
// 해당 카테고리의 캐시 제거
queryClient.removeQueries({ queryKey: queryKeys.categories.detail(categoryCode) });
},
onError: (error) => {
console.error("카테고리 삭제 실패:", error);
},
});
}

View File

@ -0,0 +1,153 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import type { CodeFilter, CreateCodeData, UpdateCodeData } from "@/lib/schemas/commonCode";
/**
*
*/
export function useCodes(categoryCode: string, filters?: CodeFilter) {
return useQuery({
queryKey: queryKeys.codes.list(categoryCode, filters),
queryFn: () => commonCodeApi.codes.getList(categoryCode, filters),
select: (data) => data.data || [],
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
});
}
/**
*
*/
export function useCreateCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) =>
commonCodeApi.codes.create(categoryCode, data),
onSuccess: (_, variables) => {
// 해당 카테고리의 모든 코드 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.list(variables.categoryCode),
});
},
onError: (error) => {
console.error("코드 생성 실패:", error);
},
});
}
/**
*
*/
export function useUpdateCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
categoryCode,
codeValue,
data,
}: {
categoryCode: string;
codeValue: string;
data: UpdateCodeData;
}) => commonCodeApi.codes.update(categoryCode, codeValue, data),
onSuccess: (_, variables) => {
// 해당 코드 상세 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
});
// 해당 카테고리의 코드 목록 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.list(variables.categoryCode),
});
},
onError: (error) => {
console.error("코드 수정 실패:", error);
},
});
}
/**
*
*/
export function useDeleteCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, codeValue }: { categoryCode: string; codeValue: string }) =>
commonCodeApi.codes.delete(categoryCode, codeValue),
onSuccess: (_, variables) => {
// 해당 코드 관련 쿼리 무효화 및 캐시 제거
queryClient.invalidateQueries({
queryKey: queryKeys.codes.list(variables.categoryCode),
});
queryClient.removeQueries({
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
});
},
onError: (error) => {
console.error("코드 삭제 실패:", error);
},
});
}
/**
*
*/
export function useReorderCodes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
categoryCode,
codes,
}: {
categoryCode: string;
codes: Array<{ codeValue: string; sortOrder: number }>;
}) => commonCodeApi.codes.reorder(categoryCode, codes),
onMutate: async ({ categoryCode, codes }) => {
// 진행 중인 쿼리들을 취소해서 optimistic update가 덮어쓰이지 않도록 함
await queryClient.cancelQueries({ queryKey: queryKeys.codes.list(categoryCode) });
// 이전 데이터를 백업
const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode));
// Optimistic update: 새로운 순서로 즉시 업데이트
if (previousCodes && (previousCodes as any).data && Array.isArray((previousCodes as any).data)) {
const previousCodesArray = (previousCodes as any).data;
// 기존 데이터를 복사하고 sort_order만 업데이트
const updatedCodes = [...previousCodesArray].map((code: any) => {
const newCodeData = codes.find((c) => c.codeValue === code.code_value);
return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code;
});
// sort_order로 정렬
updatedCodes.sort((a: any, b: any) => a.sort_order - b.sort_order);
// API 응답 형태로 캐시에 저장 (기존 구조 유지)
queryClient.setQueryData(queryKeys.codes.list(categoryCode), {
...(previousCodes as any),
data: updatedCodes,
});
}
// 롤백용 데이터 반환
return { previousCodes };
},
onError: (error, variables, context) => {
console.error("코드 순서 변경 실패:", error);
// 에러 시 이전 데이터로 롤백
if (context?.previousCodes) {
queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes);
}
},
onSettled: (_, __, variables) => {
// 성공/실패와 관계없이 최종적으로 서버 데이터로 동기화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.list(variables.categoryCode),
});
},
});
}

View File

@ -1,320 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { commonCodeApi } from "@/lib/api/commonCode";
import {
CodeCategory,
CodeInfo,
CreateCategoryRequest,
UpdateCategoryRequest,
CreateCodeRequest,
UpdateCodeRequest,
GetCategoriesQuery,
GetCodesQuery,
} from "@/types/commonCode";
/**
*
*/
export function useCommonCode() {
// 카테고리 관련 상태
const [categories, setCategories] = useState<CodeCategory[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [categoriesError, setCategoriesError] = useState<string | null>(null);
const [totalCategories, setTotalCategories] = useState(0);
// 코드 관련 상태
const [codes, setCodes] = useState<CodeInfo[]>([]);
const [codesLoading, setCodesLoading] = useState(false);
const [codesError, setCodesError] = useState<string | null>(null);
// 선택된 카테고리
const [selectedCategoryCode, setSelectedCategoryCode] = useState<string>("");
/**
*
*/
const fetchCategories = useCallback(async (params?: GetCategoriesQuery) => {
setCategoriesLoading(true);
setCategoriesError(null);
try {
const response = await commonCodeApi.categories.getList(params);
if (response.success) {
setCategories(response.data || []);
setTotalCategories(response.total || 0);
setCategoriesError(null); // 에러 상태 초기화
} else {
throw new Error(response.message || "카테고리 조회에 실패했습니다.");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
setCategoriesError(errorMessage);
console.error("카테고리 조회 오류:", error);
} finally {
setCategoriesLoading(false);
}
}, []);
/**
*
*/
const fetchCodes = useCallback(async (categoryCode: string, params?: GetCodesQuery) => {
if (!categoryCode) {
setCodes([]);
return;
}
setCodesLoading(true);
setCodesError(null);
try {
const response = await commonCodeApi.codes.getList(categoryCode, params);
if (response.success && response.data) {
setCodes(response.data);
} else {
throw new Error(response.message || "코드 조회에 실패했습니다.");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
setCodesError(errorMessage);
console.error("코드 조회 오류:", error);
} finally {
setCodesLoading(false);
}
}, []);
/**
*
*/
const createCategory = useCallback(
async (data: CreateCategoryRequest) => {
try {
const response = await commonCodeApi.categories.create(data);
if (response.success) {
// 🔥 즉시 UI 업데이트: 새로운 카테고리를 현재 목록에 추가
const newCategory = response.data;
setCategories((prevCategories) => [...prevCategories, newCategory]);
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCategories();
return response;
} else {
throw new Error(response.message || "카테고리 생성에 실패했습니다.");
}
} catch (error) {
console.error("카테고리 생성 오류:", error);
throw error;
}
},
[fetchCategories],
);
/**
*
*/
const updateCategory = useCallback(
async (categoryCode: string, data: UpdateCategoryRequest) => {
try {
const response = await commonCodeApi.categories.update(categoryCode, data);
if (response.success) {
// 🔥 즉시 UI 업데이트: 수정된 카테고리를 현재 목록에서 업데이트
const updatedCategory = response.data;
setCategories((prevCategories) =>
prevCategories.map((category) => (category.category_code === categoryCode ? updatedCategory : category)),
);
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCategories();
return response;
} else {
throw new Error(response.message || "카테고리 수정에 실패했습니다.");
}
} catch (error) {
console.error("카테고리 수정 오류:", error);
throw error;
}
},
[fetchCategories],
);
/**
*
*/
const deleteCategory = useCallback(
async (categoryCode: string) => {
try {
const response = await commonCodeApi.categories.delete(categoryCode);
if (response.success) {
// 🔥 즉시 UI 업데이트: 삭제된 카테고리를 현재 목록에서 제거
setCategories((prevCategories) =>
prevCategories.filter((category) => category.category_code !== categoryCode),
);
// 선택된 카테고리가 삭제된 경우 선택 해제
if (selectedCategoryCode === categoryCode) {
setSelectedCategoryCode(""); // 선택 해제
setCodes([]); // 코드 목록 초기화
}
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCategories();
return response;
} else {
throw new Error(response.message || "카테고리 삭제에 실패했습니다.");
}
} catch (error) {
console.error("카테고리 삭제 오류:", error);
throw error;
}
},
[fetchCategories, selectedCategoryCode],
);
/**
*
*/
const createCode = useCallback(
async (categoryCode: string, data: CreateCodeRequest) => {
try {
const response = await commonCodeApi.codes.create(categoryCode, data);
if (response.success) {
// 🔥 즉시 UI 업데이트: 새로운 코드를 현재 목록에 추가
const newCode = response.data;
setCodes((prevCodes) => [...prevCodes, newCode]);
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCodes(categoryCode);
return response;
} else {
throw new Error(response.message || "코드 생성에 실패했습니다.");
}
} catch (error) {
console.error("코드 생성 오류:", error);
throw error;
}
},
[fetchCodes],
);
/**
*
*/
const updateCode = useCallback(
async (categoryCode: string, codeValue: string, data: UpdateCodeRequest) => {
try {
const response = await commonCodeApi.codes.update(categoryCode, codeValue, data);
if (response.success) {
// 🔥 즉시 UI 업데이트: 수정된 코드를 현재 목록에서 업데이트
const updatedCode = response.data;
setCodes((prevCodes) => prevCodes.map((code) => (code.code_value === codeValue ? updatedCode : code)));
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCodes(categoryCode);
return response;
} else {
throw new Error(response.message || "코드 수정에 실패했습니다.");
}
} catch (error) {
console.error("코드 수정 오류:", error);
throw error;
}
},
[fetchCodes],
);
/**
*
*/
const deleteCode = useCallback(
async (categoryCode: string, codeValue: string) => {
try {
const response = await commonCodeApi.codes.delete(categoryCode, codeValue);
if (response.success) {
// 🔥 즉시 UI 업데이트: 삭제된 코드를 현재 목록에서 제거
setCodes((prevCodes) => prevCodes.filter((code) => code.code_value !== codeValue));
// 🔄 동시에 서버에서 최신 데이터 가져오기 (백그라운드)
fetchCodes(categoryCode);
return response;
} else {
throw new Error(response.message || "코드 삭제에 실패했습니다.");
}
} catch (error) {
console.error("코드 삭제 오류:", error);
throw error;
}
},
[fetchCodes],
);
const reorderCodes = useCallback(
async (categoryCode: string, codes: Array<{ codeValue: string; sortOrder: number }>) => {
try {
const response = await commonCodeApi.codes.reorder(categoryCode, { codes });
if (response.success) {
console.log("✅ 코드 순서 변경 성공");
} else {
throw new Error(response.message || "코드 순서 변경에 실패했습니다.");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
console.error("코드 순서 변경 오류:", error);
throw error;
}
},
[],
);
/**
*
*/
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
/**
*
*/
useEffect(() => {
if (selectedCategoryCode) {
fetchCodes(selectedCategoryCode);
} else {
setCodes([]);
}
}, [selectedCategoryCode, fetchCodes]);
return {
// 카테고리 관련
categories,
categoriesLoading,
categoriesError,
totalCategories,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
// 코드 관련
codes,
setCodes,
codesLoading,
codesError,
fetchCodes,
createCode,
updateCode,
deleteCode,
reorderCodes,
// 선택된 카테고리
selectedCategoryCode,
setSelectedCategoryCode,
};
}

View File

@ -107,7 +107,8 @@ export const commonCodeApi = {
/**
*
*/
async reorder(categoryCode: string, data: ReorderCodesRequest): Promise<ApiResponse> {
async reorder(categoryCode: string, codes: Array<{ codeValue: string; sortOrder: number }>): Promise<ApiResponse> {
const data = { codes }; // 백엔드가 기대하는 형식으로 래핑
const response = await apiClient.put(`/common-codes/categories/${categoryCode}/codes/reorder`, data);
return response.data;
},

33
frontend/lib/queryKeys.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* React Query Key Factory
*
*/
export const queryKeys = {
// 카테고리 관련 쿼리 키
categories: {
all: ["categories"] as const,
lists: () => [...queryKeys.categories.all, "list"] as const,
list: (filters?: { active?: boolean; search?: string }) => [...queryKeys.categories.lists(), filters] as const,
details: () => [...queryKeys.categories.all, "detail"] as const,
detail: (categoryCode: string) => [...queryKeys.categories.details(), categoryCode] as const,
},
// 코드 관련 쿼리 키
codes: {
all: ["codes"] as const,
lists: () => [...queryKeys.codes.all, "list"] as const,
list: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
[...queryKeys.codes.lists(), categoryCode, filters] as const,
details: () => [...queryKeys.codes.all, "detail"] as const,
detail: (categoryCode: string, codeValue: string) =>
[...queryKeys.codes.details(), categoryCode, codeValue] as const,
},
// 옵션 관련 쿼리 키 (향후 화면관리 연계용)
options: {
all: ["options"] as const,
byCategory: (categoryCode: string) => [...queryKeys.options.all, categoryCode] as const,
},
} as const;

View File

@ -0,0 +1,70 @@
import { z } from "zod";
/**
* Zod
*/
// 카테고리 스키마
export const categorySchema = z.object({
categoryCode: z
.string()
.min(1, "카테고리 코드는 필수입니다")
.max(50, "카테고리 코드는 50자 이하여야 합니다")
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
categoryName: z.string().min(1, "카테고리명은 필수입니다").max(100, "카테고리명은 100자 이하여야 합니다"),
categoryNameEng: z.string().max(100, "영문 카테고리명은 100자 이하여야 합니다").optional().or(z.literal("")),
description: z.string().max(500, "설명은 500자 이하여야 합니다").optional().or(z.literal("")),
sortOrder: z.number().min(1, "정렬 순서는 1 이상이어야 합니다").max(9999, "정렬 순서는 9999 이하여야 합니다"),
});
// 카테고리 생성 스키마
export const createCategorySchema = categorySchema;
// 카테고리 수정 스키마 (카테고리 코드 제외)
export const updateCategorySchema = categorySchema.omit({ categoryCode: true }).extend({
isActive: z.enum(["Y", "N"]),
});
// 코드 스키마
export const codeSchema = z.object({
codeValue: z
.string()
.min(1, "코드값은 필수입니다")
.max(50, "코드값은 50자 이하여야 합니다")
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
codeName: z.string().min(1, "코드명은 필수입니다").max(100, "코드명은 100자 이하여야 합니다"),
codeNameEng: z.string().min(1, "영문 코드명은 필수입니다").max(100, "영문 코드명은 100자 이하여야 합니다"),
description: z.string().min(1, "설명은 필수입니다").max(500, "설명은 500자 이하여야 합니다"),
sortOrder: z.number().min(1, "정렬 순서는 1 이상이어야 합니다").max(9999, "정렬 순서는 9999 이하여야 합니다"),
});
// 코드 생성 스키마
export const createCodeSchema = codeSchema;
// 코드 수정 스키마 (코드값 제외)
export const updateCodeSchema = codeSchema.omit({ codeValue: true }).extend({
isActive: z.enum(["Y", "N"]),
});
// TypeScript 타입 추론
export type CategoryFormData = z.infer<typeof categorySchema>;
export type CreateCategoryData = z.infer<typeof createCategorySchema>;
export type UpdateCategoryData = z.infer<typeof updateCategorySchema>;
export type CodeFormData = z.infer<typeof codeSchema>;
export type CreateCodeData = z.infer<typeof createCodeSchema>;
export type UpdateCodeData = z.infer<typeof updateCodeSchema>;
// 검색 필터 스키마
export const categoryFilterSchema = z.object({
search: z.string().optional(),
active: z.boolean().optional(),
});
export const codeFilterSchema = z.object({
search: z.string().optional(),
active: z.boolean().optional(),
});
export type CategoryFilter = z.infer<typeof categoryFilterSchema>;
export type CodeFilter = z.infer<typeof codeFilterSchema>;

View File

@ -11,7 +11,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
@ -25,6 +25,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -35,14 +36,15 @@
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"react-hook-form": "^7.62.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.10"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -2427,6 +2429,61 @@
"tailwindcss": "4.1.12"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.6.tgz",
"integrity": "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.84.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.6.tgz",
"integrity": "sha512-A6rE39FypFV7eonefk4fxC/vuV/7YJMAcQT94CFAvCpiw65QZX8MOuUpdLBeG1cXajy4Pj8T8sEWHigccntJqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.84.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.85.6",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",

View File

@ -16,7 +16,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
@ -30,6 +30,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -40,14 +41,15 @@
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"react-hook-form": "^7.62.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.10"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@ -0,0 +1,42 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
interface QueryProviderProps {
children: React.ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 5분간 캐시 유지
staleTime: 1000 * 60 * 5,
// 30분간 가비지 컬렉션 방지
gcTime: 1000 * 60 * 30,
// 에러 시 재시도 1번만
retry: 1,
// 윈도우 포커스 시 자동 리페칭 비활성화
refetchOnWindowFocus: false,
},
mutations: {
// 뮤테이션 에러 시 재시도 안함
retry: false,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
{/* 개발 환경에서만 DevTools 표시 */}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}

View File

@ -13,6 +13,9 @@ export interface CodeCategory {
updated_by?: string | null;
}
// CategoryInfo는 CodeCategory의 별칭
export type CategoryInfo = CodeCategory;
export interface CodeInfo {
code_category: string;
code_value: string;
@ -40,7 +43,7 @@ export interface UpdateCategoryRequest {
categoryNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: boolean;
isActive?: "Y" | "N"; // 백엔드에서 기대하는 문자열 타입
}
export interface CreateCodeRequest {
@ -56,7 +59,7 @@ export interface UpdateCodeRequest {
codeNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: boolean;
isActive?: "Y" | "N"; // 백엔드에서 기대하는 문자열 타입
}
export interface CodeOption {
@ -91,4 +94,3 @@ export interface ApiResponse<T = any> {
error?: string;
total?: number;
}