docs: add ERP low-code development guidelines and component usage rules
This commit is contained in:
630
docs/coding-rules/erp-coding-rules-direct.md
Normal file
630
docs/coding-rules/erp-coding-rules-direct.md
Normal file
@@ -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
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">컬럼명</TableHead>
|
||||
|
||||
// 숫자 컬럼 (우측 정렬)
|
||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
|
||||
// 너비 지정 시 앞에 추가
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
|
||||
// TableHeader 행
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
...
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
```
|
||||
|
||||
### ★ 선택 행 스타일 통일
|
||||
```tsx
|
||||
<TableRow
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isSelected ? "border-l-primary bg-primary/5" : "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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<string>` 상태 관리
|
||||
- 트리 노드 UI:
|
||||
```tsx
|
||||
<div className={cn("flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded hover:bg-muted/50",
|
||||
selected && "bg-primary/5 border-l-[3px] border-l-primary"
|
||||
)} style={{ paddingLeft: `${level * 24}px` }}>
|
||||
<ChevronRight className={cn("h-4 w-4 transition-transform", expanded && "rotate-90")} />
|
||||
<span className="text-sm">{node.name}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 pl-4">
|
||||
<Checkbox checked={allChecked} onCheckedChange={handleCheckAll} />
|
||||
</TableHead>
|
||||
{/* ★ 모든 데이터 컬럼에 동일한 스타일 적용 */}
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colCount} className="py-16 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colCount} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Inbox className="h-8 w-8 opacity-30" />
|
||||
<span className="text-sm">등록된 데이터가 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox checked={isChecked} onCheckedChange={() => toggleCheck(row.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.part_name}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{Number(row.qty).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 7-2. 데이터 셀 className 패턴
|
||||
```tsx
|
||||
// 코드/번호 → font-mono
|
||||
<TableCell className="font-mono text-[13px]">{row.order_no}</TableCell>
|
||||
|
||||
// 일반 텍스트
|
||||
<TableCell className="text-[13px]">{row.part_name}</TableCell>
|
||||
|
||||
// 숫자 → text-right font-mono
|
||||
<TableCell className="text-right font-mono text-[13px]">{Number(row.qty).toLocaleString()}</TableCell>
|
||||
|
||||
// 금액 → text-right font-mono font-semibold
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{Number(row.amount).toLocaleString()}</TableCell>
|
||||
|
||||
// 보조 정보 (규격, 메모 등) → text-muted-foreground
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
|
||||
// 긴 텍스트 → truncate
|
||||
<TableCell className="text-[13px] max-w-[150px]">
|
||||
<span className="block truncate" title={row.item_name}>{row.item_name}</span>
|
||||
</TableCell>
|
||||
|
||||
// 날짜
|
||||
<TableCell className="text-[13px]">{row.due_date}</TableCell>
|
||||
|
||||
// 뱃지
|
||||
<TableCell><Badge variant={getVariant(row.status)}>{resolveLabel(row.status, "status")}</Badge></TableCell>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 검색 필터 구현
|
||||
|
||||
```tsx
|
||||
<div className="rounded-lg border bg-card px-5 py-4">
|
||||
<div className="grid grid-cols-5 gap-3 items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">수주번호</label>
|
||||
<Input value={search} onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && fetchData()} placeholder="SO-2026-0001" className="h-9" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">기간</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="date" className="h-9 flex-1" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
<span className="text-xs text-muted-foreground/50">~</span>
|
||||
<Input type="date" className="h-9 flex-1" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-9" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4" /> 초기화
|
||||
</Button>
|
||||
<Button size="sm" className="h-9 flex-1" onClick={fetchData}>
|
||||
<Search className="w-4 h-4" /> 조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 모달(Dialog) 구현
|
||||
|
||||
```tsx
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 max-w-[95vw] w-full" style={{ maxHeight: "92vh" }}>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-5">
|
||||
<DialogTitle className="text-[17px] font-bold">{isEditMode ? "수정" : "등록"}</DialogTitle>
|
||||
<DialogDescription>정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
{/* 섹션 구분 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
|
||||
기본 정보 <div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
필드명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input className="h-9" value={form.field || ""} onChange={e => setForm(p => ({ ...p, field: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 빈 상태 구현
|
||||
|
||||
```tsx
|
||||
{/* 테이블 빈 상태 */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground py-16">
|
||||
<Inbox className="h-8 w-8 opacity-30" />
|
||||
<span className="text-sm">등록된 데이터가 없어요</span>
|
||||
</div>
|
||||
|
||||
{/* 마스터-디테일 우측 빈 상태 */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-full border-2 border-dashed rounded-lg">
|
||||
<Package className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm font-medium text-muted-foreground">좌측에서 항목을 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/70">상세 정보가 여기에 표시돼요</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 콤보박스 (Popover + Command)
|
||||
|
||||
```tsx
|
||||
<Popover open={comboOpen} onOpenChange={setComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-9 w-full justify-between text-sm font-normal">
|
||||
{selectedValue ? options.find(o => o.code === selectedValue)?.label : "선택해주세요"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없어요</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map(opt => (
|
||||
<CommandItem key={opt.code} value={opt.label} onSelect={() => { setSelectedValue(opt.code); setComboOpen(false); }}>
|
||||
{opt.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. 테이블 헤더 스타일을 페이지마다 다르게 적용 (★ 통일 규격 준수)
|
||||
526
docs/coding-rules/erp-coding-rules-lowcode.md
Normal file
526
docs/coding-rules/erp-coding-rules-lowcode.md
Normal file
@@ -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" } }
|
||||
},
|
||||
];
|
||||
|
||||
<DataGrid
|
||||
columns={gridColumns}
|
||||
data={data}
|
||||
selectedId={selectedId}
|
||||
checkedIds={checkedIds}
|
||||
onRowClick={handleRowClick}
|
||||
onCheckChange={handleCheckChange}
|
||||
onSave={handleSave}
|
||||
loading={loading}
|
||||
emptyMessage="데이터가 없어요"
|
||||
/>
|
||||
```
|
||||
|
||||
### 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 },
|
||||
];
|
||||
|
||||
<DynamicSearchFilter
|
||||
fields={FILTER_CONFIG}
|
||||
values={filterValues}
|
||||
onChange={setFilterValues}
|
||||
onSearch={handleSearch}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2-3. SmartSelect (검색 가능 셀렉트)
|
||||
- 파일: `@/components/common/SmartSelect`
|
||||
- 용도: 검색 + 선택 통합 드롭다운 (거래처, 품목 선택 등)
|
||||
|
||||
```tsx
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
|
||||
<SmartSelect
|
||||
value={selectedCode}
|
||||
onChange={setSelectedCode}
|
||||
options={customerOptions}
|
||||
placeholder="거래처를 선택해주세요"
|
||||
searchable
|
||||
/>
|
||||
```
|
||||
|
||||
### 2-4. FullscreenDialog (전체화면 다이얼로그)
|
||||
- 파일: `@/components/common/FullscreenDialog`
|
||||
- 용도: 등록/수정 시 전체화면 모달
|
||||
|
||||
```tsx
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||
>
|
||||
{/* 폼 내용 */}
|
||||
</FullscreenDialog>
|
||||
```
|
||||
|
||||
### 2-5. TableSettingsModal (테이블 설정)
|
||||
- 파일: `@/components/common/TableSettingsModal`
|
||||
- 용도: 컬럼 표시/숨김, 순서 변경, 너비 조정
|
||||
|
||||
```tsx
|
||||
import { TableSettingsModal, loadTableSettings, saveTableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
<TableSettingsModal
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
columns={gridColumns}
|
||||
onSave={(newColumns) => { setGridColumns(newColumns); saveTableSettings(SETTINGS_KEY, newColumns); }}
|
||||
settingsKey={SETTINGS_KEY}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2-6. FormDatePicker (날짜 선택)
|
||||
- 파일: `@/components/common/FormDatePicker`
|
||||
- 용도: 캘린더 UI 기반 날짜 입력
|
||||
|
||||
```tsx
|
||||
import { FormDatePicker } from "@/components/common/FormDatePicker";
|
||||
|
||||
<FormDatePicker
|
||||
value={formData.order_date}
|
||||
onChange={(date) => 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<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [filterValues, setFilterValues] = useState<FilterValue>({});
|
||||
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col h-full gap-3 p-4">
|
||||
{/* 검색 필터 */}
|
||||
<DynamicSearchFilter
|
||||
fields={FILTER_CONFIG}
|
||||
values={filterValues}
|
||||
onChange={setFilterValues}
|
||||
onSearch={fetchData}
|
||||
onReset={() => { setFilterValues({}); }}
|
||||
/>
|
||||
|
||||
{/* 메인 카드 */}
|
||||
<div className="flex-1 flex flex-col border rounded-lg bg-card overflow-hidden">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">목록</h3>
|
||||
<Badge variant="secondary" className="text-xs font-mono">{totalCount}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => { setIsEditMode(false); setFormData({}); setIsModalOpen(true); }}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> 등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="text-destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 그리드 */}
|
||||
<DataGrid
|
||||
columns={gridColumns}
|
||||
data={data}
|
||||
selectedId={selectedId}
|
||||
checkedIds={checkedIds}
|
||||
onRowClick={(row) => setSelectedId(row.id)}
|
||||
onCheckChange={setCheckedIds}
|
||||
loading={loading}
|
||||
emptyMessage="데이터가 없어요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<FullscreenDialog open={isModalOpen} onOpenChange={setIsModalOpen} title={isEditMode ? "수정" : "등록"}>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* SmartSelect, FormDatePicker 등 공통 컴포넌트 활용 */}
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
columns={gridColumns}
|
||||
onSave={setGridColumns}
|
||||
settingsKey={SETTINGS_KEY}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 테이블과 비즈니스 로직에 맞게 구성
|
||||
Reference in New Issue
Block a user