527 lines
17 KiB
Markdown
527 lines
17 KiB
Markdown
|
|
# 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 테이블과 비즈니스 로직에 맞게 구성
|