공통코드 관리 화면 wace comm_code 단일 테이블로 전환
vexplor 로코드툴의 code_category/code_info 2-테이블 구조 대신 wace_plm 원본 comm_code(847건) 위에서 직접 동작하도록 백엔드 service 전면 재작성. 영업관리 등 이미 comm_code에 의존 중인 화면들과 데이터 소스 일치. - backend service: comm_code 단일 테이블 기반(top-level=parent_code_id 빈값) 으로 재작성. 다단계 계층은 재귀 CTE로 depth/parent_code_value 산출. 단일 테넌시라 company_code/menu_objid 필터링은 시그니처만 유지하고 무시 - comm_code에 sort_order INT 컬럼 추가(드래그 정렬 유지) — DB 마이그레이션 - frontend schema: 영문명/설명을 optional로 완화(wace 컬럼 부재) - 카테고리/코드 폼 모달에서 영문명·설명 입력 필드 제거 - 패널 레이아웃을 화면 높이에 고정 + 좌우 패널 자체 스크롤로 분리 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,18 +150,11 @@ export class CommonCodeController {
|
||||
});
|
||||
}
|
||||
|
||||
if (!menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "메뉴 OBJID는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const category = await this.commonCodeService.createCategory(
|
||||
categoryData,
|
||||
userId,
|
||||
companyCode,
|
||||
Number(menuObjid)
|
||||
menuObjid ? Number(menuObjid) : undefined
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,8 @@
|
||||
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
|
||||
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
|
||||
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"commonCode.page.title",
|
||||
"commonCode.page.description",
|
||||
@@ -14,7 +12,6 @@ const LANG_KEYS = [
|
||||
"commonCode.detail.title",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"commonCode.page.title": "공통코드 관리",
|
||||
"commonCode.page.description": "시스템에서 사용하는 공통코드를 관리합니다",
|
||||
@@ -31,41 +28,36 @@ export default function CommonCodeManagementPage() {
|
||||
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("commonCode.page.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("commonCode.page.description")}</p>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-6">
|
||||
{/* 페이지 헤더 (고정) */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("commonCode.page.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("commonCode.page.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 - 좌우 레이아웃 (남은 공간 가득) */}
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col gap-6 lg:flex-row">
|
||||
{/* 좌측: 카테고리 패널 */}
|
||||
<div className="flex min-h-0 w-full flex-col lg:w-80 lg:border-r lg:pr-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">{t("commonCode.category.title")}</h2>
|
||||
<div className="min-h-0 flex-1">
|
||||
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-6">
|
||||
{/* 좌측: 카테고리 패널 */}
|
||||
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("commonCode.category.title")}</h2>
|
||||
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 코드 상세 패널 */}
|
||||
<div className="min-w-0 flex-1 lg:pl-0">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("commonCode.detail.title")}
|
||||
{selectedCategoryCode && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||
</div>
|
||||
{/* 우측: 코드 상세 패널 */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
{t("commonCode.detail.title")}
|
||||
{selectedCategoryCode && (
|
||||
<span className="text-muted-foreground ml-2 text-sm font-normal">({selectedCategoryCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="min-h-0 flex-1">
|
||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
@@ -89,22 +88,14 @@ export function CodeCategoryFormModal({
|
||||
validatedFields.has("categoryName"),
|
||||
);
|
||||
|
||||
const categoryNameEngCheck = useCheckCategoryDuplicate(
|
||||
"categoryNameEng",
|
||||
isEditing ? updateForm.watch("categoryNameEng") : createForm.watch("categoryNameEng"),
|
||||
isEditing ? editingCategoryCode : undefined,
|
||||
validatedFields.has("categoryNameEng"),
|
||||
);
|
||||
|
||||
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||
const hasDuplicateErrors =
|
||||
(!isEditing && categoryCodeCheck.data?.isDuplicate && validatedFields.has("categoryCode")) ||
|
||||
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName")) ||
|
||||
(categoryNameEngCheck.data?.isDuplicate && validatedFields.has("categoryNameEng"));
|
||||
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName"));
|
||||
|
||||
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||
const isDuplicateChecking =
|
||||
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
|
||||
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading;
|
||||
|
||||
// 폼은 조건부로 직접 사용
|
||||
|
||||
@@ -240,72 +231,6 @@ export function CodeCategoryFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryNameEng">카테고리 영문명 *</Label>
|
||||
<Input
|
||||
id="categoryNameEng"
|
||||
{...(isEditing ? updateForm.register("categoryNameEng") : createForm.register("categoryNameEng"))}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리 영문명을 입력하세요"
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.categoryNameEng
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("categoryNameEng")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
|
||||
)}
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
: createForm.formState.errors.categoryNameEng) && (
|
||||
<ValidationMessage
|
||||
message={categoryNameEngCheck.data?.message}
|
||||
isValid={!categoryNameEngCheck.data?.isDuplicate}
|
||||
isLoading={categoryNameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명 *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...(isEditing ? updateForm.register("description") : createForm.register("description"))}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요"
|
||||
rows={3}
|
||||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.description
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.description
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("description")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">정렬 순서</Label>
|
||||
|
||||
@@ -92,13 +92,13 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 검색 및 액션 (고정) */}
|
||||
<div className="space-y-3 pb-4">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="카테고리 검색..."
|
||||
value={searchTerm}
|
||||
@@ -119,16 +119,16 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
id="activeOnly"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
className="border-input h-4 w-4 rounded"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
|
||||
<label htmlFor="activeOnly" className="text-muted-foreground text-sm">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 목록 (무한 스크롤) */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
{/* 카테고리 목록 (자체 스크롤 + 무한 스크롤) */}
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -247,9 +247,9 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 검색 및 액션 (고정) */}
|
||||
<div className="space-y-3 pb-4">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
@@ -282,8 +282,8 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 코드 목록 (무한 스크롤) */}
|
||||
<div className="space-y-3" onScroll={handleScroll}>
|
||||
{/* 코드 목록 (자체 스크롤 + 무한 스크롤) */}
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
|
||||
@@ -217,35 +216,6 @@ export function CodeFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 (선택) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeNameEng" className="text-xs sm:text-sm">
|
||||
코드 영문명
|
||||
</Label>
|
||||
<Input
|
||||
id="codeNameEng"
|
||||
{...form.register("codeNameEng")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드 영문명을 입력하세요 (선택사항)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 (선택) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...form.register("description")}
|
||||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요 (선택사항)"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 부모 코드 표시 (하위 코드 추가 시에만 표시, 읽기 전용) */}
|
||||
{defaultParentCode && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
*/
|
||||
|
||||
// 카테고리 스키마
|
||||
// 공통코드 저장소가 wace `comm_code` 단일 테이블이라 영문명/설명 컬럼이 없음 → 선택 입력으로 둠.
|
||||
export const categorySchema = z.object({
|
||||
categoryCode: z
|
||||
.string()
|
||||
@@ -12,11 +13,8 @@ export const categorySchema = z.object({
|
||||
.max(50, "카테고리 코드는 50자 이하여야 합니다")
|
||||
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
|
||||
categoryName: z.string().min(1, "카테고리명은 필수입니다").max(100, "카테고리명은 100자 이하여야 합니다"),
|
||||
categoryNameEng: z
|
||||
.string()
|
||||
.min(1, "영문 카테고리명은 필수입니다")
|
||||
.max(100, "영문 카테고리명은 100자 이하여야 합니다"),
|
||||
description: z.string().min(1, "설명은 필수입니다").max(500, "설명은 500자 이하여야 합니다"),
|
||||
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 이하여야 합니다"),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user