현대적 라이브러리 도입 완료
This commit is contained in:
parent
40b2328876
commit
8b495b9e80
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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%)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue