Files
vexplor_dev/docs/coding-rules/erp-coding-rules-lowcode.md

527 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 테이블과 비즈니스 로직에 맞게 구성