17 KiB
17 KiB
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 - 용도: 편집 가능한 데이터 테이블 (컬럼 정의만으로 테이블 렌더링)
- 기능: 정렬, 행 선택, 체크박스, 인라인 편집, 컬럼 리사이즈
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 자동 생성
- 기능: 텍스트, 셀렉트, 날짜 범위, 콤보박스 필터 자동 렌더링
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 - 용도: 검색 + 선택 통합 드롭다운 (거래처, 품목 선택 등)
import { SmartSelect } from "@/components/common/SmartSelect";
<SmartSelect
value={selectedCode}
onChange={setSelectedCode}
options={customerOptions}
placeholder="거래처를 선택해주세요"
searchable
/>
2-4. FullscreenDialog (전체화면 다이얼로그)
- 파일:
@/components/common/FullscreenDialog - 용도: 등록/수정 시 전체화면 모달
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
>
{/* 폼 내용 */}
</FullscreenDialog>
2-5. TableSettingsModal (테이블 설정)
- 파일:
@/components/common/TableSettingsModal - 용도: 컬럼 표시/숨김, 순서 변경, 너비 조정
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 기반 날짜 입력
import { FormDatePicker } from "@/components/common/FormDatePicker";
<FormDatePicker
value={formData.order_date}
onChange={(date) => setFormData(prev => ({ ...prev, order_date: date }))}
placeholder="날짜를 선택해주세요"
/>
2-7. 기타 사용 가능 컴포넌트
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 기본 (공통 컴포넌트와 혼용)
"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만)
import {
Plus, Trash2, Save, Loader2, Search, X, Pencil,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
RotateCcw, Download, Upload, FileSpreadsheet, Settings2,
} from "lucide-react";
유틸
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)
// ★ 목록 조회 (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. 카테고리 옵션 로드
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. 코드→라벨 변환
const resolveLabel = (code: string, optionKey: string): string => {
const opts = categoryOptions[optionKey] || [];
return opts.find(o => o.code === code)?.label || code || "-";
};
6-4. 마스터-디테일 저장 패턴
// 신규: 마스터 add → 디테일 각 행 add
// 수정: 마스터 edit → 기존 디테일 delete → 디테일 재 add
// 시스템 필드 제외: id, created_date, updated_date, writer, company_code, created_by, updated_by
7. 로우코드 페이지 기본 골격
"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 레지스트리 등록
"/{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. 절대 금지 사항
fetch()직접 사용 (반드시apiClient사용)- 하드코딩 색상 (
bg-gray-*,bg-white,text-black,#hex) - 새 라이브러리 설치
console.log남기기any타입 남발 (최소한의 타입 정의)- 비즈니스 로직 변경 (UI 리디자인 태스크일 때)
- Card-in-Card 중첩
- 인라인 콘텐츠에
max-h/overflow-y-auto
10. 화면별 고유 디자인 원칙
- 다른 화면을 "복사"하지 말 것. 각 화면의 컬럼/필드/모달은 독립 설계
ref_files에 다른 화면 코드가 있어도 패턴 참고일 뿐- 각 화면의 DB 테이블과 비즈니스 로직에 맞게 구성