From 1348ad118db9d05172ccda4eb4c8a3cb8f325a8d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 3 Apr 2026 14:02:32 +0900 Subject: [PATCH] docs: add ERP low-code development guidelines and component usage rules --- docs/coding-rules/erp-coding-rules-direct.md | 630 ++++++++++++++++++ docs/coding-rules/erp-coding-rules-lowcode.md | 526 +++++++++++++++ .../components/common/TableSettingsModal.tsx | 42 +- 3 files changed, 1184 insertions(+), 14 deletions(-) create mode 100644 docs/coding-rules/erp-coding-rules-direct.md create mode 100644 docs/coding-rules/erp-coding-rules-lowcode.md diff --git a/docs/coding-rules/erp-coding-rules-direct.md b/docs/coding-rules/erp-coding-rules-direct.md new file mode 100644 index 00000000..5edf748d --- /dev/null +++ b/docs/coding-rules/erp-coding-rules-direct.md @@ -0,0 +1,630 @@ +# 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. 테이블 헤더 스타일을 페이지마다 다르게 적용 (★ 통일 규격 준수) diff --git a/docs/coding-rules/erp-coding-rules-lowcode.md b/docs/coding-rules/erp-coding-rules-lowcode.md new file mode 100644 index 00000000..ed8ae170 --- /dev/null +++ b/docs/coding-rules/erp-coding-rules-lowcode.md @@ -0,0 +1,526 @@ +# ERP 화면 로우코드 규칙 (Low-Code Mode) + +이 문서는 **로우코드** 방식으로 화면을 개발/리디자인할 때 참조하는 규칙입니다. +DataGrid, DynamicSearchFilter 등 공통 컴포넌트를 활용하여 설정(config) 기반으로 빠르게 화면을 구성합니다. + +> **적용 대상:** COMPANY_7(탑씰) 기존 화면, COMPANY_29, COMPANY_9 등 공통 컴포넌트 기반 화면 +> **주의:** 이 모드는 기존 로우코드 화면 유지보수용. 신규 개발은 날코딩 규칙(`erp-coding-rules-rawcode.md`) 권장. + +--- + +## 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 +- **공통 컴포넌트:** frontend/components/common/ + +--- + +## 2. 사용 가능한 공통 컴포넌트 + +### 2-1. DataGrid (테이블) +- 파일: `@/components/common/DataGrid` +- 용도: 편집 가능한 데이터 테이블 (컬럼 정의만으로 테이블 렌더링) +- 기능: 정렬, 행 선택, 체크박스, 인라인 편집, 컬럼 리사이즈 + +```tsx +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "order_no", label: "수주번호", width: "w-[120px]", type: "text" }, + { key: "part_code", label: "품번", width: "w-[120px]", editable: true }, + { key: "qty", label: "수량", width: "w-[80px]", type: "number", align: "right" }, + { key: "status", label: "상태", width: "w-[100px]", type: "badge", + badgeMap: { active: { label: "확정", variant: "default" }, draft: { label: "작성중", variant: "secondary" } } + }, +]; + + +``` + +### 2-2. DynamicSearchFilter (검색 필터) +- 파일: `@/components/common/DynamicSearchFilter` +- 용도: 설정 기반 동적 검색 필터 UI 자동 생성 +- 기능: 텍스트, 셀렉트, 날짜 범위, 콤보박스 필터 자동 렌더링 + +```tsx +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; + +const FILTER_CONFIG = [ + { key: "order_no", label: "수주번호", type: "text" }, + { key: "status", label: "상태", type: "select", options: statusOptions }, + { key: "order_date", label: "수주일", type: "dateRange" }, + { key: "customer", label: "거래처", type: "combo", options: customerOptions }, +]; + + +``` + +### 2-3. SmartSelect (검색 가능 셀렉트) +- 파일: `@/components/common/SmartSelect` +- 용도: 검색 + 선택 통합 드롭다운 (거래처, 품목 선택 등) + +```tsx +import { SmartSelect } from "@/components/common/SmartSelect"; + + +``` + +### 2-4. FullscreenDialog (전체화면 다이얼로그) +- 파일: `@/components/common/FullscreenDialog` +- 용도: 등록/수정 시 전체화면 모달 + +```tsx +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; + + + {/* 폼 내용 */} + +``` + +### 2-5. TableSettingsModal (테이블 설정) +- 파일: `@/components/common/TableSettingsModal` +- 용도: 컬럼 표시/숨김, 순서 변경, 너비 조정 + +```tsx +import { TableSettingsModal, loadTableSettings, saveTableSettings } from "@/components/common/TableSettingsModal"; + + { setGridColumns(newColumns); saveTableSettings(SETTINGS_KEY, newColumns); }} + settingsKey={SETTINGS_KEY} +/> +``` + +### 2-6. FormDatePicker (날짜 선택) +- 파일: `@/components/common/FormDatePicker` +- 용도: 캘린더 UI 기반 날짜 입력 + +```tsx +import { FormDatePicker } from "@/components/common/FormDatePicker"; + + setFormData(prev => ({ ...prev, order_date: date }))} + placeholder="날짜를 선택해주세요" +/> +``` + +### 2-7. 기타 사용 가능 컴포넌트 + +```tsx +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; // 확인 다이얼로그 훅 +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; // 엑셀 업로드 +import { exportToExcel } from "@/lib/utils/excelExport"; // 엑셀 다운로드 유틸 +import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; // 출하계획 배치 +import { TimelineScheduler } from "@/components/common/TimelineScheduler"; // 타임라인 스케줄러 +import { EditableSpreadsheet } from "@/components/common/EditableSpreadsheet"; // 스프레드시트형 편집 +``` + +--- + +## 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +``` + +### 아이콘 (lucide-react만) +```tsx +import { + Plus, Trash2, Save, Loader2, Search, X, Pencil, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, + RotateCcw, Download, Upload, FileSpreadsheet, Settings2, +} 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-muted-foreground, bg-muted, bg-destructive, border-border +❌ 금지: bg-gray-50, bg-white, text-black, text-gray-500, #3b82f6 +``` + +### 4-2. 테마 +- 라이트 모드 기본 + `.dark` 클래스로 다크 모드 자동 전환 +- Primary: Vivid Blue (HSL 217 91% 60%) +- Dark 배경: Deep Navy (HSL 222 47% 6%) + +### 4-3. 컴포넌트 규칙 +- shadcn/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. 화면 유형별 레이아웃 + +### Type A: 단일 테이블형 +``` +page-container(flex flex-col h-full gap-3 p-4) + ├─ DynamicSearchFilter + ├─ card(flex-1 flex flex-col border rounded-lg) + │ ├─ panel-header (타이틀 + 건수 Badge + 액션 버튼) + │ ├─ DataGrid (컬럼 정의로 자동 렌더링) + │ └─ pagination + └─ FullscreenDialog (등록/수정 모달) +``` + +### Type B: 마스터-디테일형 (좌우 분할) +``` +page-container + ├─ DynamicSearchFilter + └─ ResizablePanelGroup(horizontal) + ├─ ResizablePanel (좌측: DataGrid) + ├─ ResizableHandle + └─ ResizablePanel (우측: 상세 Tabs) + ├─ 미선택: empty-state + └─ 선택: Tabs (탭별 DataGrid 또는 상세 폼) +``` + +### Type C: 트리+디테일형 +``` +Type B + 우측에 트리뷰 + 상세카드 +``` + +### Type D: 탭 멀티뷰형 +``` +page-container + └─ card > Tabs + └─ TabsContent × N (각각 DynamicSearchFilter + DataGrid) +``` + +### Type E: 카드 리스트형 +``` +Type B에서 좌측을 DataGrid 대신 카드 리스트로 구현 +``` + +### Type F: 리포트형 +``` +ReportEngine에 config 전달 (선언적) +``` + +--- + +## 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 }); + } + + 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, +}); + +// ★ 수정 (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. 로우코드 페이지 기본 골격 + +```tsx +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { TableSettingsModal, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { SmartSelect } from "@/components/common/SmartSelect"; +import { FormDatePicker } from "@/components/common/FormDatePicker"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { Plus, Trash2, Download, Upload, Settings2, Loader2 } from "lucide-react"; + +const TABLE_NAME = "your_table"; +const SETTINGS_KEY = "table_settings_your_page"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "col1", label: "컬럼1", width: "w-[120px]" }, + { key: "col2", label: "컬럼2", width: "w-[100px]", type: "number" }, + { key: "status", label: "상태", width: "w-[100px]", type: "badge" }, +]; + +const FILTER_CONFIG = [ + { key: "col1", label: "컬럼1", type: "text" }, + { key: "status", label: "상태", type: "select", options: [] }, +]; + +export default function YourPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); + const [filterValues, setFilterValues] = useState({}); + const [gridColumns, setGridColumns] = useState(GRID_COLUMNS); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [saving, setSaving] = useState(false); + const [formData, setFormData] = useState>({}); + + const [selectedId, setSelectedId] = useState(null); + const [checkedIds, setCheckedIds] = useState([]); + const [settingsOpen, setSettingsOpen] = useState(false); + + const [categoryOptions, setCategoryOptions] = useState>({}); + + const fetchData = useCallback(async () => { /* table-management API */ }, [filterValues]); + const loadCategories = useCallback(async () => { /* 카테고리 로드 */ }, []); + + useEffect(() => { loadCategories(); }, [loadCategories]); + useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + const saved = loadTableSettings(SETTINGS_KEY); + if (saved) setGridColumns(saved); + }, []); + + return ( +
+ {/* 검색 필터 */} + { setFilterValues({}); }} + /> + + {/* 메인 카드 */} +
+ {/* 패널 헤더 */} +
+
+

목록

+ {totalCount} +
+
+ + + +
+
+ + {/* 데이터 그리드 */} + setSelectedId(row.id)} + onCheckChange={setCheckedIds} + loading={loading} + emptyMessage="데이터가 없어요" + /> +
+ + {/* 등록/수정 모달 */} + +
+ {/* SmartSelect, FormDatePicker 등 공통 컴포넌트 활용 */} +
+
+ + {/* 테이블 설정 모달 */} + + + {ConfirmDialogComponent} +
+ ); +} +``` + +--- + +## 8. 메뉴 등록 규칙 (신규 화면 필수) + +### 8-1. AdminPageRenderer 레지스트리 등록 +```tsx +"/{COMPANY}/{category}/{screen}": dynamic( + () => import("@/app/(main)/{COMPANY}/{category}/{screen}/page"), + { ssr: false, loading: LoadingFallback } +), +``` + +### 8-2. menu_info 테이블 업데이트 +- `menu_url`: 새 경로 / `screen_code`: NULL / `screen_group_id`: NULL + +### 8-3. screen_menu_assignments 비활성화 +해당 메뉴의 모든 할당을 `is_active = 'N'`으로 변경. + +--- + +## 9. 절대 금지 사항 + +1. `fetch()` 직접 사용 (반드시 `apiClient` 사용) +2. 하드코딩 색상 (`bg-gray-*`, `bg-white`, `text-black`, `#hex`) +3. 새 라이브러리 설치 +4. `console.log` 남기기 +5. `any` 타입 남발 (최소한의 타입 정의) +6. 비즈니스 로직 변경 (UI 리디자인 태스크일 때) +7. Card-in-Card 중첩 +8. 인라인 콘텐츠에 `max-h` / `overflow-y-auto` + +--- + +## 10. 화면별 고유 디자인 원칙 + +- 다른 화면을 "복사"하지 말 것. 각 화면의 컬럼/필드/모달은 독립 설계 +- `ref_files`에 다른 화면 코드가 있어도 **패턴 참고**일 뿐 +- 각 화면의 DB 테이블과 비즈니스 로직에 맞게 구성 diff --git a/frontend/components/common/TableSettingsModal.tsx b/frontend/components/common/TableSettingsModal.tsx index 1c5fa16c..87442868 100644 --- a/frontend/components/common/TableSettingsModal.tsx +++ b/frontend/components/common/TableSettingsModal.tsx @@ -107,19 +107,25 @@ export function loadTableSettings(settingsId: string): TableSettings | null { } } -/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */ +/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 (활성 컬럼 위, 비활성 아래) */ function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] { const savedMap = new Map(saved.map((s) => [s.columnName, s])); - const ordered: ColumnSetting[] = []; - // 저장된 순서대로 + const visible: ColumnSetting[] = []; + const hidden: ColumnSetting[] = []; + // 저장된 순서대로 복원하되, 활성/비활성 분리 for (const s of saved) { const f = fresh.find((c) => c.columnName === s.columnName); - if (f) ordered.push({ ...f, visible: s.visible, width: s.width }); + if (f) { + const merged = { ...f, visible: s.visible, width: s.width }; + if (s.visible) visible.push(merged); + else hidden.push(merged); + } } // 새로 추가된 컬럼은 맨 뒤에 for (const f of fresh) { - if (!savedMap.has(f.columnName)) ordered.push(f); + if (!savedMap.has(f.columnName)) hidden.push(f); } + const ordered = [...visible, ...hidden]; return ordered; } @@ -162,13 +168,7 @@ function SortableColumnRow({ - {/* 표시 체크박스 */} - onToggleVisible(col._idx)} - /> - - {/* 표시 토글 (Switch) */} + {/* 표시 토글 */} onToggleVisible(col._idx)} @@ -287,7 +287,19 @@ export function TableSettingsModal({ // localStorage에서 저장된 설정 복원 const saved = loadTableSettings(settingsId); if (saved) { - setTempColumns(mergeColumns(freshColumns, saved.columns)); + let merged = mergeColumns(freshColumns, saved.columns); + // defaultVisibleKeys 순서로 활성 컬럼 재정렬 + if (defaultVisibleKeys) { + const visibleInOrder = defaultVisibleKeys + .map((key) => merged.find((c) => c.columnName === key && c.visible)) + .filter((c): c is ColumnSetting => !!c); + const visibleExtra = merged.filter( + (c) => c.visible && !defaultVisibleKeys.includes(c.columnName), + ); + const hidden = merged.filter((c) => !c.visible); + merged = [...visibleInOrder, ...visibleExtra, ...hidden]; + } + setTempColumns(merged); setTempFilters(freshFilters.map((f) => { const s = saved.filters?.find((sf) => sf.columnName === f.columnName); return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f; @@ -326,10 +338,12 @@ export function TableSettingsModal({ onOpenChange(false); }; - // 컬럼 설정 초기화 + // 컬럼 설정 초기화 (defaultVisibleKeys 기준으로 리셋) const handleResetColumns = () => { setTempColumns(defaultColumns.map((c) => ({ ...c }))); setTempFrozenCount(0); + // localStorage도 제거하여 완전 초기화 + localStorage.removeItem(getStorageKey(settingsId)); }; // ===== 컬럼 설정 핸들러 =====