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

17 KiB
Raw Blame History

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. 절대 금지 사항

  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 테이블과 비즈니스 로직에 맞게 구성