# ERP 화면 직접 구현 규칙 (Direct Implementation Mode) 이 문서는 **직접 구현** 방식으로 화면을 개발/리디자인할 때 참조하는 규칙입니다. 프리셋 HTML(Type A~F) 디자인 규격에 따라 shadcn/ui 기본 컴포넌트로 직접 조립하여 구현합니다. > **적용 대상:** COMPANY_16(하이큐마그) 전체, 향후 신규 화면 개발 > **로우코드와의 차이:** 로우코드는 DataGrid/DynamicSearchFilter 등 공통 컴포넌트에 설정만 넘기는 방식. 직접 구현은 프리셋 규격에 맞춰 shadcn/ui 기본으로 직접 조립. --- ## 1. 프로젝트 정보 - **프로젝트:** erp-node (Next.js App Router + Node.js/Express + PostgreSQL) - **프론트엔드:** /Users/gbpark/erp-node/frontend/ - **백엔드:** /Users/gbpark/erp-node/backend-node/ - **화면 경로:** frontend/app/(main)/{COMPANY}/{category}/{screen}/page.tsx - **프리셋 HTML:** _local/erp-preset-type-{a~f}-*.html - **공통 CSS:** _local/erp-preset-common.css - **글로벌 CSS:** frontend/app/globals.css (테이블 Enhancement 포함) --- ## 2. 절대 금지 컴포넌트 (★ 최우선 규칙) 아래 공통 컴포넌트는 **절대 import/사용 금지**. shadcn/ui 기본으로 직접 구현: | 금지 | 대체 방법 | |------|-----------| | `DataGrid` | `Table, TableBody, TableCell, TableHead, TableHeader, TableRow` 직접 조합 | | `DynamicSearchFilter` | `Input, Select, Button`으로 검색 영역 직접 구현 | | `SmartSelect` | `Select` 또는 `Popover + Command` 조합으로 직접 구현 | | `FullscreenDialog` | `Dialog` (shadcn/ui)로 직접 구현 | | `TableSettingsModal` | 필요 시 인라인으로 직접 구현 | | `FormDatePicker` | `Input type="date"` 또는 `Calendar` 컴포넌트 직접 구현 | ### 사용 가능한 공통 컴포넌트 (예외) ```tsx import { useConfirmDialog } from "@/components/common/ConfirmDialog"; // OK (로직 훅) import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; // OK (복잡한 유틸) import { exportToExcel } from "@/lib/utils/excelExport"; // OK (유틸 함수) import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; // OK (도메인 모달) import { TimelineScheduler } from "@/components/common/TimelineScheduler"; // OK (특수 컴포넌트) ``` --- ## 3. 허용 import 패턴 ### shadcn/ui 기본 (필수) ```tsx "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; ``` ### 아이콘 (lucide-react만) ```tsx import { Plus, Trash2, Save, Loader2, Search, X, Pencil, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, RotateCcw, Download, Upload, FileSpreadsheet, Settings2, ChevronDown, ChevronUp, Package, Inbox, } from "lucide-react"; ``` ### 유틸 ```tsx import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { exportToExcel } from "@/lib/utils/excelExport"; ``` --- ## 4. 디자인 규칙 ### 4-1. 색상: CSS 변수만 사용 (하드코딩 절대 금지) ``` ✅ 허용: bg-primary, text-primary-foreground, bg-muted, text-muted-foreground bg-destructive, text-destructive, bg-accent, text-accent-foreground bg-card, text-card-foreground, bg-background, text-foreground border-border, border-primary, border-destructive hsl(var(--primary)), hsl(var(--border)), hsl(var(--muted)) ❌ 금지 (하드코딩): bg-gray-50, bg-gray-100, bg-white, bg-slate-* text-black, text-gray-500, text-blue-500 border-gray-200, border-slate-* #3b82f6, #ffffff, rgb(0,0,0) ``` ### 4-2. 테마 - 라이트 모드 기본 + `.dark` 클래스로 다크 모드 자동 전환 - Primary: Vivid Blue (HSL 217 91% 60%) - Dark 배경: Deep Navy (HSL 222 47% 6%) ### 4-3. 컴포넌트 규칙 - shadcn/ui 컴포넌트만 사용 (`@/components/ui/*`) - 아이콘: lucide-react만 사용 - Card-in-Card 중첩 금지 - 인라인 콘텐츠에 `max-h` / `overflow-y-auto` 금지 - 새 UI 라이브러리 설치 금지 ### 4-4. 텍스트 톤 - Toss 스타일 **~해요 체** 사용 - 빈 상태: "수주를 등록해주세요", "좌측에서 거래처를 선택해주세요" - 확인 다이얼로그: "삭제하시겠어요?", "저장되었어요" - 플레이스홀더: "품목명을 입력해주세요" ### 4-5. 사이즈 표준 - 입력필드 높이: `h-9` (36px) - 버튼 기본: `h-9`, 소형: `h-8` (`size="sm"`) - 폰트: `text-sm` (14px) 기본, `text-xs` (12px) 보조 --- ## 5. 프리셋 6종 — 화면 유형별 레이아웃 ### ★ 테이블 공통 스타일 (globals.css에서 전역 적용) globals.css에 아래 스타일이 전역 적용되므로, 각 페이지에서는 구조만 맞추면 됨: - **데이터 셀**: padding 12px 16px, font-size 14px, line-height 1.5 - **헤더 셀**: padding 12px 16px, font-size 14px, font-weight 700 - **짝수 행 stripe**: 미세 배경색 (라이트: muted/0.35, 다크: muted/0.18) - **hover**: bg-accent - **다크모드 체크박스**: 밝은 테두리 (흰색 계열) ### ★ 테이블 헤더 통일 규격 (★ 반드시 준수) 모든 테이블의 TableHead 데이터 컬럼에 아래 className을 적용: ```tsx 컬럼명 // 숫자 컬럼 (우측 정렬) 금액 // 너비 지정 시 앞에 추가 품번 // TableHeader 행 ... ``` ### ★ 선택 행 스타일 통일 ```tsx ``` --- ### Type A: 단일 테이블형 - 프리셋: `_local/erp-preset-type-a-single-table.html` - 적용 페이지: sales/order, purchase/order, master-data/item-info, logistics/packaging, production/work-instruction, quality/item-inspection, design/change-management, design/design-request - 구조: ``` page-container(flex flex-col h-full gap-3 p-4) ├─ 브레드크럼 (text-xs text-muted-foreground) ├─ 검색 필터 (card: rounded-lg border bg-card px-5 py-4) │ ├─ grid(grid-cols-5 gap-3 items-end) │ │ ├─ field-group(flex flex-col gap-1) × N │ │ │ ├─ label(text-[11px] font-semibold uppercase tracking-wide text-muted-foreground) │ │ │ └─ Input(h-9) / Select(h-9) / date range │ │ └─ 조회/초기화 Button (h-9) ├─ 액션 바 (flex justify-between) │ ├─ 타이틀(text-lg font-bold) + 건수(font-mono text-[11px] text-primary bg-primary/5 rounded-full) │ └─ 버튼 그룹 (등록/수정/삭제 | 엑셀업로드/다운로드 | 설정) ├─ 테이블 카드 (flex-1 overflow-hidden rounded-lg border bg-card) │ └─ Table (★ 테이블 공통 스타일 적용) │ ├─ TableHeader(sticky top-0 z-10) → TableRow(bg-muted hover:bg-muted) │ │ └─ TableHead(text-[11px] font-bold uppercase tracking-wide text-muted-foreground) │ └─ TableBody → 데이터 행 (선택 행 스타일, hover) └─ 등록/수정 모달 (Dialog: max-w-[95vw] max-h-[92vh]) ├─ DialogHeader (타이틀 + 설명) ├─ 4열 그리드 폼 (grid grid-cols-4 gap-4) │ └─ Label(text-[11px] font-semibold uppercase) + Input(h-9) / Select / date ├─ 디테일 리피터 (마스터-디테일인 경우) └─ DialogFooter (취소 + 저장) ``` ### Type B: 마스터-디테일형 (좌우 분할) - 프리셋: `_local/erp-preset-type-b-master-detail.html` - 적용 페이지: sales/customer, sales/sales-item, sales/claim, sales/shipping-*, purchase/purchase-item, purchase/supplier, logistics/inventory, logistics/warehouse, logistics/outbound, logistics/receiving, logistics/material-status, master-data/department, equipment/info, outsourcing/*, design/task-management, mold/info, production/plan-management - 구조: ``` page-container(flex flex-col h-full gap-3 p-4) ├─ 검색 필터 (Type A와 동일 구조) └─ ResizablePanelGroup(direction="horizontal" className="flex-1") ├─ ResizablePanel(defaultSize={55} minSize={30}) ← 좌측: 마스터 │ └─ card(flex flex-col h-full border rounded-lg bg-card) │ ├─ 패널 헤더(flex justify-between p-3 border-b bg-muted) │ │ ├─ 타이틀(text-[13px] font-bold) + 건수 Badge │ │ └─ 버튼 (등록/수정/삭제/설정) │ └─ Table (★ 테이블 공통 스타일 적용) ├─ ResizableHandle(withHandle) └─ ResizablePanel(defaultSize={45} minSize={25}) ← 우측: 상세 ├─ 미선택 시: empty-state │ └─ border-2 border-dashed rounded-lg + 아이콘 + "좌측에서 ~를 선택해주세요" └─ 선택 시: 상세 콘텐츠 또는 Tabs └─ Tabs (탭별 상세 테이블/폼) ``` ### Type C: 트리+디테일형 - 프리셋: `_local/erp-preset-type-c-tree-detail.html` - 적용 페이지: production/bom, master-data/company, design/project - Type B와 동일하되 우측에 **트리뷰 + 상세카드** - 트리 노드: 재귀 컴포넌트로 직접 구현 (라이브러리 금지) - 트리 indent: `pl-{level * 6}` - expand/collapse: `expandedNodes: Set` 상태 관리 - 트리 노드 UI: ```tsx
{node.name}
``` ### Type D: 탭 멀티뷰형 - 프리셋: `_local/erp-preset-type-d-tab-multiview.html` - 적용 페이지: equipment/plc-settings, logistics/info, quality/inspection - 구조: ``` page-container(flex flex-col h-full gap-3 p-4) └─ card(flex-1 flex flex-col border rounded-lg) ├─ Tabs (shadcn) │ ├─ TabsList │ │ └─ TabsTrigger × N (탭 이름 + 건수 Badge) │ └─ TabsContent × N │ ├─ 패널 헤더 (타이틀 + 버튼) │ └─ Table (★ 테이블 공통 스타일 적용) └─ 등록/수정 모달 ``` ### Type E: 카드 리스트형 - 프리셋: `_local/erp-preset-type-e-card-list.html` - 적용 페이지: design/my-work (칸반/리스트/타임시트 뷰) - Type B에서 좌측이 Table 대신 카드 리스트 - 카드: `border rounded-lg p-4 cursor-pointer hover:border-primary` ### Type F: 리포트형 - 프리셋: `_local/erp-preset-type-f-report.html` - 적용 페이지: 현재 없음 (향후 대시보드/리포트 화면용) - 선언적 config 객체 + ReportEngine 사용 - 차트 + 요약 카드 + 상세 테이블 구조 --- ## 6. 공통 API 패턴 ### 6-1. 범용 CRUD (table-management) ```tsx // ★ 목록 조회 (POST) const fetchData = async () => { setLoading(true); try { const filters: any[] = []; if (searchValue) { filters.push({ columnName: "column_name", operator: "contains", value: searchValue }); } if (searchStatus !== "all") { filters.push({ columnName: "status", operator: "equals", value: searchStatus }); } const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { page: currentPage, size: pageSize, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "created_date", order: "desc" }, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; const total = res.data?.data?.totalCount || res.data?.data?.total || 0; setData(rows); setTotalCount(total); } catch (err) { toast.error("조회에 실패했어요"); } finally { setLoading(false); } }; // ★ 등록 (POST /add) await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { field1: value1, field2: value2, // 시스템 필드 제외: id, created_date, updated_date, writer, company_code, created_by, updated_by }); // ★ 수정 (PUT /edit) await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { originalData: { id: selectedId }, updatedData: { field1: newValue1, field2: newValue2 }, }); // ★ 삭제 (DELETE /delete) await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: selectedIds.map(id => ({ id })), }); ``` ### 6-2. 카테고리 옵션 로드 ```tsx const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${COLUMN}/values`); const flatten = (items: any[]): { code: string; label: string }[] => { return items.reduce((acc, item) => { acc.push({ code: item.code || item.value, label: item.label || item.name }); if (item.children?.length > 0) { acc.push(...flatten(item.children)); } return acc; }, [] as any[]); }; const options = res.data?.data?.length > 0 ? flatten(res.data.data) : []; ``` ### 6-3. 코드→라벨 변환 ```tsx const resolveLabel = (code: string, optionKey: string): string => { const opts = categoryOptions[optionKey] || []; return opts.find(o => o.code === code)?.label || code || "-"; }; ``` ### 6-4. 마스터-디테일 저장 패턴 ```tsx // 신규: 마스터 add → 디테일 각 행 add // 수정: 마스터 edit → 기존 디테일 delete → 디테일 재 add // 시스템 필드 제외: id, created_date, updated_date, writer, company_code, created_by, updated_by ``` --- ## 7. 테이블 구현 상세 ### 7-1. 기본 테이블 구조 (★ 모든 테이블에 이 패턴 적용) ```tsx
{/* ★ 모든 데이터 컬럼에 동일한 스타일 적용 */} 품번 품명 수량 {loading ? ( ) : data.length === 0 ? (
등록된 데이터가 없어요
) : ( data.map(row => ( toggleCheck(row.id)} /> {row.part_code} {row.part_name} {Number(row.qty).toLocaleString()} )) )}
``` ### 7-2. 데이터 셀 className 패턴 ```tsx // 코드/번호 → font-mono {row.order_no} // 일반 텍스트 {row.part_name} // 숫자 → text-right font-mono {Number(row.qty).toLocaleString()} // 금액 → text-right font-mono font-semibold {Number(row.amount).toLocaleString()} // 보조 정보 (규격, 메모 등) → text-muted-foreground {row.spec} // 긴 텍스트 → truncate {row.item_name} // 날짜 {row.due_date} // 뱃지 {resolveLabel(row.status, "status")} ``` --- ## 8. 검색 필터 구현 ```tsx
setSearch(e.target.value)} onKeyDown={e => e.key === "Enter" && fetchData()} placeholder="SO-2026-0001" className="h-9" />
setDateFrom(e.target.value)} /> ~ setDateTo(e.target.value)} />
``` --- ## 9. 모달(Dialog) 구현 ```tsx {isEditMode ? "수정" : "등록"} 정보를 입력해주세요
{/* 섹션 구분 */}
기본 정보
setForm(p => ({ ...p, field: e.target.value }))} />
``` --- ## 10. 빈 상태 구현 ```tsx {/* 테이블 빈 상태 */}
등록된 데이터가 없어요
{/* 마스터-디테일 우측 빈 상태 */}

좌측에서 항목을 선택해주세요

상세 정보가 여기에 표시돼요

``` --- ## 11. 콤보박스 (Popover + Command) ```tsx 결과가 없어요 {options.map(opt => ( { setSelectedValue(opt.code); setComboOpen(false); }}> {opt.label} ))} ``` --- ## 12. 유틸 함수 ```tsx // 천단위 포맷 const formatNumber = (val: string | number) => { const num = String(val).replace(/[^\d.-]/g, ""); if (!num) return ""; const parts = num.split("."); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); return parts.join("."); }; const parseNumber = (val: string) => val.replace(/,/g, ""); ``` --- ## 13. 메뉴 등록 규칙 (신규 화면 필수) ### 13-1. AdminPageRenderer 레지스트리 등록 ```tsx "/{COMPANY}/{category}/{screen}": dynamic( () => import("@/app/(main)/{COMPANY}/{category}/{screen}/page"), { ssr: false, loading: LoadingFallback } ), ``` ### 13-2. menu_info 테이블 업데이트 - `menu_url`: 새 경로 / `screen_code`: NULL / `screen_group_id`: NULL ### 13-3. screen_menu_assignments 비활성화 해당 메뉴의 모든 할당을 `is_active = 'N'`으로 변경. **메뉴 표시 우선순위:** 1. `screen_menu_assignments` (is_active='Y') → 로우코드 화면 2. `menu_info.screen_group_id` → 로우코드 화면 3. `menu_info.menu_url` → 직접 구현 화면 (AdminPageRenderer) → 1, 2를 비활성화해야 3번이 동작함 --- ## 14. UI 리디자인 규칙 (기존 화면 수정 시) 1. 기존 return문(JSX)을 **전부 삭제**하고 프리셋 HTML 기준으로 **새로 작성** 2. `useState`, `useEffect`, `useCallback`, API 호출 함수는 **그대로 유지** 3. DataGrid 등 공통 컴포넌트를 쓰고 있으면 **제거하고 shadcn/ui 기본으로 교체** 4. "현재 코드가 이미 충족한다"고 판단하지 말 것. **무조건 프리셋 기준으로 새로 작성** 5. git diff 기준 **최소 100줄 이상 변경** 필수 --- ## 15. 절대 금지 사항 1. `fetch()` 직접 사용 (반드시 `apiClient` 사용) 2. 하드코딩 색상 (`bg-gray-*`, `bg-white`, `text-black`, `#hex`) 3. 새 라이브러리 설치 4. `console.log` 남기기 5. `any` 타입 남발 (최소한의 타입 정의) 6. 비즈니스 로직 변경 (UI 리디자인 태스크일 때) 7. 다른 화면 UI를 그대로 복사 (패턴 참고만, 컬럼/필드는 독립 설계) 8. DataGrid, DynamicSearchFilter, SmartSelect, FullscreenDialog, FormDatePicker import 9. 테이블 헤더 스타일을 페이지마다 다르게 적용 (★ 통일 규격 준수)